feat(spec-091): backup schedule lifecycle
This commit is contained in:
parent
7876e91b72
commit
03e74b2596
@ -35,8 +35,6 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -49,11 +47,13 @@
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
@ -363,6 +363,11 @@ public static function table(Table $table): Table
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
TrashedFilter::make()
|
||||
->label('Archived')
|
||||
->placeholder('Active')
|
||||
->trueLabel('All')
|
||||
->falseLabel('Archived'),
|
||||
SelectFilter::make('enabled_state')
|
||||
->label('Enabled')
|
||||
->options([
|
||||
@ -394,6 +399,7 @@ public static function table(Table $table): Table
|
||||
->label('Run now')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
@ -456,6 +462,7 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
@ -463,6 +470,7 @@ public static function table(Table $table): Table
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
@ -525,6 +533,7 @@ public static function table(Table $table): Table
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
@ -533,11 +542,137 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
DeleteAction::make()
|
||||
Action::make('archive')
|
||||
->label('Archive')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
Gate::authorize('delete', $record);
|
||||
|
||||
if ($record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.archived',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule archived')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('restore')
|
||||
->label('Restore')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
Gate::authorize('restore', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.restored',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule restored')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
Gate::authorize('forceDelete', $record);
|
||||
|
||||
if (! $record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->operationRuns()->exists()) {
|
||||
Notification::make()
|
||||
->title('Cannot force delete backup schedule')
|
||||
->body('Backup schedules referenced by historical runs cannot be removed.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.force_deleted',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
@ -739,12 +874,6 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
DeleteBulkAction::make('bulk_delete')
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
]);
|
||||
}
|
||||
@ -759,6 +888,11 @@ public static function getEloquentQuery(): Builder
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return static::getEloquentQuery()->withTrashed();
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -4,11 +4,26 @@
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class EditBackupSchedule extends EditRecord
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
$record = BackupScheduleResource::getEloquentQuery()
|
||||
->withTrashed()
|
||||
->find($key);
|
||||
|
||||
if ($record === null) {
|
||||
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||
|
||||
@ -86,6 +86,7 @@ public function handle(
|
||||
}
|
||||
|
||||
$schedule = BackupSchedule::query()
|
||||
->withTrashed()
|
||||
->with('tenant')
|
||||
->find($backupScheduleId);
|
||||
|
||||
@ -126,6 +127,59 @@ public function handle(
|
||||
$operationRunService->updateRun($this->operationRun, 'running');
|
||||
}
|
||||
|
||||
if ($schedule->trashed()) {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$this->finishSchedule(
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
nowUtc: $nowUtc,
|
||||
);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Blocked->value,
|
||||
summaryCounts: [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
'code' => 'schedule_archived',
|
||||
'message' => 'Schedule is archived; run will not execute.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'Schedule is archived; run will not execute.',
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_skipped',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'operation_run_id' => $this->operationRun->getKey(),
|
||||
'reason' => 'schedule_archived',
|
||||
],
|
||||
],
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $this->operationRun->getKey(),
|
||||
status: 'partial'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
|
||||
@ -6,10 +6,12 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BackupSchedule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
|
||||
@ -75,11 +75,24 @@ public function tenants(): BelongsToMany
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function workspaces(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Workspace::class, 'workspace_memberships')
|
||||
->using(WorkspaceMembership::class)
|
||||
->withPivot(['id', 'role'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function tenantMemberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
}
|
||||
|
||||
public function workspaceMemberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceMembership::class);
|
||||
}
|
||||
|
||||
public function tenantPreferences(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserTenantPreference::class);
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class WorkspaceMembership extends Model
|
||||
class WorkspaceMembership extends Pivot
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WorkspaceMembershipFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'workspace_memberships';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
|
||||
@ -50,6 +50,7 @@ public function update(User $user, BackupSchedule $schedule): bool
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}
|
||||
|
||||
@ -58,6 +59,25 @@ public function delete(User $user, BackupSchedule $schedule): bool
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}
|
||||
|
||||
public function restore(User $user, BackupSchedule $schedule): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, BackupSchedule $schedule): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$schedulesQuery = BackupSchedule::query()
|
||||
->withoutTrashed()
|
||||
->where('is_enabled', true)
|
||||
->whereHas('tenant', fn ($query) => $query->where('status', 'active'))
|
||||
->with('tenant');
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('backup_schedules', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
$table->index(['tenant_id', 'deleted_at'], 'backup_schedules_tenant_deleted_at_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backup_schedules', function (Blueprint $table) {
|
||||
$table->dropIndex('backup_schedules_tenant_deleted_at_idx');
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-13
|
||||
**Feature**: [specs/091-backupschedule-retention-lifecycle/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass: Spec contains no placeholders, no clarification markers, and defines lifecycle semantics, RBAC boundaries (404/403), confirmation rules, audit events, and operational correctness in a testable way.
|
||||
- The “UI Action Matrix” section is present because the feature modifies admin UI action surfaces; it is kept at the requirements/UX level (labels, confirmation, gating) without prescribing code.
|
||||
@ -0,0 +1,135 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot Internal UI Contracts
|
||||
version: 1.0.0
|
||||
description: |
|
||||
This OpenAPI document is used as a schema bundle for internal (non-HTTP) contracts.
|
||||
|
||||
Spec 091 does not introduce public HTTP endpoints; Filament/Livewire actions are
|
||||
executed via framework internals. We still publish schemas here so the feature
|
||||
has explicit, reviewable contracts under `specs/.../contracts/`.
|
||||
|
||||
paths: {}
|
||||
|
||||
components:
|
||||
schemas:
|
||||
BackupScheduleLifecycleAction:
|
||||
type: string
|
||||
description: Stable identifiers for lifecycle mutations.
|
||||
enum:
|
||||
- backup_schedule.archived
|
||||
- backup_schedule.restored
|
||||
- backup_schedule.force_deleted
|
||||
|
||||
BackupScheduleLifecycleGuard:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- can_archive
|
||||
- can_restore
|
||||
- can_force_delete
|
||||
- force_delete_blocked_reason
|
||||
properties:
|
||||
can_archive:
|
||||
type: boolean
|
||||
can_restore:
|
||||
type: boolean
|
||||
can_force_delete:
|
||||
type: boolean
|
||||
force_delete_blocked_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Human-friendly reason shown when force delete is blocked.
|
||||
examples:
|
||||
- Historical runs exist for this schedule.
|
||||
|
||||
BackupScheduleLifecycleMutation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- action
|
||||
- tenant_id
|
||||
- backup_schedule_id
|
||||
properties:
|
||||
action:
|
||||
$ref: '#/components/schemas/BackupScheduleLifecycleAction'
|
||||
tenant_id:
|
||||
type: integer
|
||||
backup_schedule_id:
|
||||
type: integer
|
||||
actor_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
occurred_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Sanitized metadata payload.
|
||||
|
||||
RbacDecision:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- is_member
|
||||
- has_capability
|
||||
- outcome
|
||||
properties:
|
||||
is_member:
|
||||
type: boolean
|
||||
has_capability:
|
||||
type: boolean
|
||||
outcome:
|
||||
type: string
|
||||
description: Server-side outcome required by RBAC-UX.
|
||||
enum:
|
||||
- allow
|
||||
- deny_as_not_found
|
||||
- deny_as_forbidden
|
||||
|
||||
AuditLogEntry:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- action
|
||||
- status
|
||||
- recorded_at
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
workspace_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
actor_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
actor_email:
|
||||
type: string
|
||||
nullable: true
|
||||
actor_name:
|
||||
type: string
|
||||
nullable: true
|
||||
action:
|
||||
type: string
|
||||
description: Stable action id string.
|
||||
examples:
|
||||
- backup_schedule.archived
|
||||
resource_type:
|
||||
type: string
|
||||
nullable: true
|
||||
resource_id:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
enum: [success]
|
||||
metadata:
|
||||
type: object
|
||||
description: Sanitized metadata payload.
|
||||
additionalProperties: true
|
||||
recorded_at:
|
||||
type: string
|
||||
format: date-time
|
||||
58
specs/091-backupschedule-retention-lifecycle/data-model.md
Normal file
58
specs/091-backupschedule-retention-lifecycle/data-model.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Data Model — Spec 091 (BackupSchedule Retention & Lifecycle)
|
||||
|
||||
## Entities
|
||||
|
||||
### BackupSchedule (existing)
|
||||
|
||||
**Table**: `backup_schedules`
|
||||
|
||||
**Existing fields (selected)**
|
||||
- `id` (pk)
|
||||
- `tenant_id` (fk → `tenants.id`)
|
||||
- `name` (string)
|
||||
- `is_enabled` (bool)
|
||||
- `timezone` (string)
|
||||
- `frequency` (`daily|weekly`)
|
||||
- `time_of_day` (time)
|
||||
- `days_of_week` (json nullable)
|
||||
- `policy_types` (json)
|
||||
- `include_foundations` (bool)
|
||||
- `retention_keep_last` (int)
|
||||
- `last_run_at` (datetime nullable)
|
||||
- `last_run_status` (string nullable)
|
||||
- `next_run_at` (datetime nullable)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**New fields (this spec)**
|
||||
- `deleted_at` (datetime nullable)
|
||||
|
||||
**State**
|
||||
- Active: `deleted_at = null`
|
||||
- Archived: `deleted_at != null`
|
||||
|
||||
**Validation / rules (lifecycle)**
|
||||
- Archive is allowed only for active schedules.
|
||||
- Restore is allowed only for archived schedules.
|
||||
- Force delete is allowed only for archived schedules.
|
||||
- Force delete is blocked if historical runs exist.
|
||||
|
||||
**Relationships (existing)**
|
||||
- `tenant(): BelongsTo`
|
||||
- `operationRuns(): HasMany` (derived from `operation_runs` using `tenant_id` + `context->backup_schedule_id`)
|
||||
|
||||
## Operational Records
|
||||
|
||||
### OperationRun (existing)
|
||||
|
||||
Historical runs for backup schedules are represented by `operation_runs` filtered via `BackupSchedule::operationRuns()`.
|
||||
|
||||
**Force delete constraint source**
|
||||
- “Historical runs exist” is defined as: `BackupSchedule::operationRuns()->exists()`.
|
||||
|
||||
## Indexing / performance notes
|
||||
|
||||
- Add an index supporting common list queries for schedules:
|
||||
- at minimum, `backup_schedules(deleted_at)` for soft delete scoping
|
||||
- consider composite index like `backup_schedules(tenant_id, deleted_at)` if query planner needs it
|
||||
|
||||
No other schema changes are required for this feature.
|
||||
197
specs/091-backupschedule-retention-lifecycle/plan.md
Normal file
197
specs/091-backupschedule-retention-lifecycle/plan.md
Normal file
@ -0,0 +1,197 @@
|
||||
|
||||
# Implementation Plan: BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete) (Spec 091)
|
||||
|
||||
**Branch**: `091-backupschedule-retention-lifecycle` | **Date**: 2026-02-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce an enterprise lifecycle for tenant backup schedules:
|
||||
- **Archive** is the default destructive action (soft delete), removing schedules from the active list and preventing execution.
|
||||
- **Restore** re-activates archived schedules without changing their enabled/disabled state.
|
||||
- **Force delete** permanently removes archived schedules, but is strictly capability-gated and **blocked if the schedule has historical runs**.
|
||||
|
||||
All lifecycle actions:
|
||||
- enforce tenant isolation + capability-first RBAC (non-member → 404, member missing capability → 403),
|
||||
- require confirmation for destructive actions (archive + force delete),
|
||||
- write stable audit events.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Framework**: Laravel 12
|
||||
**Admin UI**: Filament v5 + Livewire v4.0+
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`)
|
||||
**Target Platform**: Docker/Sail local; Dokploy staging/prod
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**: Ensure dispatch due schedules remains streaming (`cursor()`), no N+1.
|
||||
**Constraints**:
|
||||
- No new dependencies
|
||||
- No bulk destructive actions for BackupSchedule lifecycle (per spec)
|
||||
- Archived schedules must never be dispatched or executed
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- ✅ Inventory-first vs backups: This feature affects backups scheduling only, not inventory state.
|
||||
- ✅ Read/write separation: Lifecycle mutations are DB-only, confirmed, audited, and tested.
|
||||
- ✅ Graph contract path: No Graph calls introduced.
|
||||
- ✅ Deterministic capabilities: Use `App\Support\Auth\Capabilities` constants only.
|
||||
- ✅ RBAC-UX semantics: non-member tenant access → 404; member missing capability → 403; actions use central helpers.
|
||||
- ✅ Destructive confirmation: archive + force delete require `->requiresConfirmation()`.
|
||||
- ✅ Filament Action Surface Contract: list keeps clickable row inspect; max 2 visible row actions; destructive actions grouped under “More”; empty-state CTA exists.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── contracts/
|
||||
└── backup-schedule-lifecycle-v1.openapi.yaml
|
||||
```
|
||||
|
||||
### Source Code (relevant to this spec)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Console/Commands/TenantpilotDispatchBackupSchedules.php
|
||||
├── Filament/Resources/BackupScheduleResource.php
|
||||
├── Filament/Resources/BackupScheduleResource/Pages/*
|
||||
├── Jobs/RunBackupScheduleJob.php
|
||||
├── Models/BackupSchedule.php
|
||||
├── Services/BackupScheduling/BackupScheduleDispatcher.php
|
||||
├── Services/Intune/AuditLogger.php
|
||||
└── Support/Rbac/UiEnforcement.php
|
||||
|
||||
database/migrations/
|
||||
└── 2026_01_05_011014_create_backup_schedules_table.php
|
||||
|
||||
tests/Feature/BackupScheduling/
|
||||
├── BackupScheduleBulkDeleteTest.php
|
||||
├── BackupScheduleCrudTest.php
|
||||
├── DispatchIdempotencyTest.php
|
||||
├── RunBackupScheduleJobTest.php
|
||||
└── RunNowRetryActionsTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel monolith; changes are limited to existing model/migration, the Filament resource/pages, the dispatcher/job safety behavior, and targeted Pest tests.
|
||||
|
||||
## Phase 0 — Outline & Research (DOCS COMPLETE)
|
||||
|
||||
Completed in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/research.md`.
|
||||
|
||||
Key findings:
|
||||
- The repo already uses soft-delete lifecycle patterns in Filament (e.g., Backup Sets) via `TrashedFilter` + custom actions.
|
||||
- Central RBAC enforcement exists for Filament actions via `App\Support\Rbac\UiEnforcement` (membership-driven hidden UI + capability-driven disabled UI + server-side guard).
|
||||
- Scheduler dispatch is centralized in `BackupScheduleDispatcher::dispatchDue()` and already uses idempotent `OperationRun` identities.
|
||||
|
||||
## Phase 1 — Design & Contracts (DOCS COMPLETE)
|
||||
|
||||
Outputs:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/contracts/backup-schedule-lifecycle-v1.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/quickstart.md`
|
||||
|
||||
Design choices:
|
||||
- Use **Eloquent SoftDeletes** on `BackupSchedule` to represent “Archived”.
|
||||
- Default list shows active schedules; archived schedules are accessible via a labeled `TrashedFilter`.
|
||||
- Lifecycle actions are implemented as Filament actions (not controllers) and wrapped via `UiEnforcement`:
|
||||
- Archive / Restore require `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE`.
|
||||
- Force delete requires `Capabilities::TENANT_DELETE`.
|
||||
- Force delete is blocked if historical runs exist. In this codebase, “historical runs” are represented by `OperationRun` records with `context->backup_schedule_id`.
|
||||
- Scheduler ignores archived schedules by default via SoftDeletes query scoping; job execution path also skips if a schedule is archived between dispatch and execution.
|
||||
|
||||
## Phase 1 — Agent Context Update (DONE)
|
||||
|
||||
The agent context update script is executed after Phase 1 artifacts are generated:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
- ✅ Livewire v4.0+ compliant (Filament v5)
|
||||
- ✅ No Graph calls introduced
|
||||
- ✅ Lifecycle actions are confirmed + audited
|
||||
- ✅ RBAC-UX membership/capability semantics remain enforced by central helpers + existing tenant routing
|
||||
- ✅ Action Surface Contract remains satisfied (no bulk destructive actions introduced)
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
### Step 1 — Add SoftDeletes to BackupSchedule
|
||||
|
||||
1) Add `SoftDeletes` trait to `App\Models\BackupSchedule`.
|
||||
2) Create a migration to add `deleted_at` to `backup_schedules` (with an index that supports common queries, e.g. `(tenant_id, deleted_at)` or similar).
|
||||
|
||||
### Step 2 — Update the Filament BackupSchedule Resource to reflect lifecycle
|
||||
|
||||
Primary goal: replace “hard delete” semantics with an **Archive / Restore / Force delete** lifecycle, while staying compliant with the Action Surface Contract.
|
||||
|
||||
Work items:
|
||||
- Add `TrashedFilter` to the table filters with “Archived” labels matching the repo conventions.
|
||||
- Remove/disable bulk destructive actions for schedules (per spec).
|
||||
- Replace any `DeleteAction` usage for schedules with an “Archive” action that calls `$record->delete()`.
|
||||
- Add “Restore” action (`$record->restore()`), visible only for archived records, with **no required confirmation**.
|
||||
- Add “Force delete” action (`$record->forceDelete()`), visible only for archived records, with confirmation required.
|
||||
- Keep at most 2 visible row actions; group lifecycle actions under a `More` menu (`ActionGroup`).
|
||||
- Ensure edit remains the primary inspection affordance (`recordUrl()` to Edit).
|
||||
|
||||
### Step 3 — Enforce RBAC semantics consistently
|
||||
|
||||
- Wrap all lifecycle actions using `UiEnforcement`:
|
||||
- Archive/Restore → `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE`
|
||||
- Force delete → `Capabilities::TENANT_DELETE`
|
||||
- Preserve visibility conditions (e.g., only show Restore/Force delete when `trashed()`).
|
||||
|
||||
### Step 4 — Implement force delete blocking (historical runs)
|
||||
|
||||
- Define “historical runs exist” as: schedule has `OperationRun` records in `BackupSchedule::operationRuns()`.
|
||||
- Force delete must fail closed:
|
||||
- In UI: show a deterministic danger notification and do not delete.
|
||||
- Title: “Cannot force delete backup schedule”
|
||||
- Body: “Backup schedules referenced by historical runs cannot be removed.”
|
||||
- In server-side action handler: early return without deleting and without writing the `backup_schedule.force_deleted` audit event.
|
||||
|
||||
### Step 5 — Audit logging for lifecycle actions
|
||||
|
||||
Use `App\Services\Intune\AuditLogger` to emit stable action IDs:
|
||||
- `backup_schedule.archived`
|
||||
- `backup_schedule.restored`
|
||||
- `backup_schedule.force_deleted`
|
||||
|
||||
Include tenant context, actor, resource type/id, and metadata (schedule id, schedule name, and an “archived_at/restored_at” timestamp if helpful).
|
||||
|
||||
### Step 6 — Operational correctness: archived schedules never execute
|
||||
|
||||
- Verify `BackupScheduleDispatcher::dispatchDue()` excludes archived schedules (SoftDeletes default scoping).
|
||||
- Update `RunBackupScheduleJob` to explicitly detect a schedule that becomes archived after being queued and **skip execution** (no Graph calls):
|
||||
- Mark the `OperationRun` as terminal `completed` with outcome `blocked`.
|
||||
- Use deterministic summary counts for the skip case (mirrors existing skip patterns): `total=0`, `processed=0`, `failed=0`, `skipped=1`.
|
||||
- Add a deterministic failure_summary entry: `code=schedule_archived`, `message=Schedule is archived; run will not execute.`
|
||||
- Update the schedule’s `last_run_status` to `skipped` (and `last_run_at` to “now”) so operators can see why the last queued run didn’t execute.
|
||||
- Emit the existing operational audit event `backup_schedule.run_skipped` with `reason=schedule_archived` (consistent with the existing `concurrent_run` skip path).
|
||||
|
||||
### Step 7 — Testing plan (Pest)
|
||||
|
||||
Minimum programmatic verification (update existing tests where applicable):
|
||||
- Archive schedule: sets `deleted_at`, excludes from active list, audit event written.
|
||||
- Restore schedule: clears `deleted_at` and does not change `is_enabled`, audit event written.
|
||||
- Force delete:
|
||||
- only available when archived
|
||||
- requires `Capabilities::TENANT_DELETE`
|
||||
- blocked when historical runs exist
|
||||
- audit event written on success
|
||||
- Scheduler safety: archived schedules are not dispatched.
|
||||
|
||||
Expected test updates:
|
||||
- `tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php` must be removed or rewritten, since bulk destructive actions are forbidden by Spec 091.
|
||||
|
||||
### Step 8 — Formatting
|
||||
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
40
specs/091-backupschedule-retention-lifecycle/quickstart.md
Normal file
40
specs/091-backupschedule-retention-lifecycle/quickstart.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Quickstart — Spec 091 (BackupSchedule Retention & Lifecycle)
|
||||
|
||||
## Goal
|
||||
|
||||
Verify BackupSchedule lifecycle (Archive/Restore/Force delete) behavior, RBAC semantics, audit logging, and scheduler safety.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker/Sail running: `vendor/bin/sail up -d`
|
||||
|
||||
## Run focused tests
|
||||
|
||||
- Run BackupScheduling tests:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling`
|
||||
|
||||
- Run RBAC helper tests (guardrails for 404/403 semantics and destructive confirmation):
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php`
|
||||
|
||||
## Manual verification checklist (admin UI)
|
||||
|
||||
In tenant panel (`/admin/t/{tenant}`):
|
||||
|
||||
- List shows active schedules by default.
|
||||
- “Archived” filter/view shows archived schedules.
|
||||
- Archive action:
|
||||
- requires confirmation
|
||||
- moves schedule to archived view
|
||||
- schedule is no longer dispatched/executed
|
||||
- Restore action:
|
||||
- restores schedule to active view
|
||||
- does not change `is_enabled`
|
||||
- Force delete action:
|
||||
- visible only when archived
|
||||
- requires confirmation
|
||||
- blocked if historical runs exist
|
||||
|
||||
## Formatting
|
||||
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
54
specs/091-backupschedule-retention-lifecycle/research.md
Normal file
54
specs/091-backupschedule-retention-lifecycle/research.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Research — Spec 091 (BackupSchedule Retention & Lifecycle)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1 — Use Eloquent SoftDeletes for “Archived” state
|
||||
- **Chosen**: Add `SoftDeletes` to `App\Models\BackupSchedule` and add `deleted_at` to `backup_schedules`.
|
||||
- **Rationale**: The repo already models reversible “archive” lifecycles using SoftDeletes (e.g., `BackupSet`) and Filament’s `TrashedFilter`. SoftDeletes also makes “archived schedules never execute” the default behavior in scheduler queries.
|
||||
- **Alternatives considered**:
|
||||
- `archived_at` boolean/timestamp field → rejected (would require manual scoping in every query and more room for mistakes).
|
||||
|
||||
### Decision 2 — Archived schedules MUST never be dispatched nor executed
|
||||
- **Chosen**:
|
||||
- Scheduler dispatch path (`BackupScheduleDispatcher::dispatchDue`) relies on default SoftDeletes query scoping (archived schedules excluded).
|
||||
- Job execution path (`RunBackupScheduleJob`) will add an explicit guard to skip if the schedule is archived at execution time.
|
||||
- **Rationale**: Prevents race conditions where a schedule is archived after dispatch but before job execution.
|
||||
- **Alternatives considered**:
|
||||
- Only guard in dispatcher → rejected (not sufficient for race conditions).
|
||||
|
||||
### Decision 3 — Reuse central RBAC enforcement helpers for lifecycle actions
|
||||
- **Chosen**: Wrap Filament actions using `App\Support\Rbac\UiEnforcement`.
|
||||
- **Rationale**: `UiEnforcement` implements the constitution’s RBAC-UX semantics (non-member hidden + server guard; member missing capability disabled + server guard) and validates capabilities against the canonical registry.
|
||||
- **Alternatives considered**:
|
||||
- Inline `->visible()`, `->disabled()`, `->authorize()` scattered in the resource → rejected (drift + violates “central helper” guidance).
|
||||
|
||||
### Decision 4 — Force delete capability and constraints
|
||||
- **Chosen**:
|
||||
- Force delete is available only when the schedule is archived.
|
||||
- Force delete is gated by `Capabilities::TENANT_DELETE` (stricter than manage).
|
||||
- Force delete is blocked if historical runs exist.
|
||||
- **Rationale**: Matches the spec clarification: permanent deletion must be highly privileged and must not break historical traceability.
|
||||
- **Alternatives considered**:
|
||||
- Allow force delete with cascading deletion of historical runs → rejected (breaks auditability).
|
||||
|
||||
### Decision 5 — Define “historical runs exist” using existing operational records
|
||||
- **Chosen**: Treat “historical runs exist” as: `BackupSchedule::operationRuns()->exists()`.
|
||||
- **Rationale**: In this repo, the scheduler and run lifecycle already track schedule activity via `OperationRun` with `context->backup_schedule_id`.
|
||||
- **Alternatives considered**:
|
||||
- Use `backup_schedule_runs` table → not currently used by the scheduling runtime (it exists in migrations but the runtime path is `OperationRun`).
|
||||
|
||||
### Decision 6 — Audit logging via existing tenant audit logger
|
||||
- **Chosen**: Use `App\Services\Intune\AuditLogger` with stable action identifiers:
|
||||
- `backup_schedule.archived`
|
||||
- `backup_schedule.restored`
|
||||
- `backup_schedule.force_deleted`
|
||||
- **Rationale**: The repo already centralizes audit logging and sanitizes metadata.
|
||||
|
||||
## Repo Evidence (Key Findings)
|
||||
|
||||
- Scheduling dispatch is centralized in `App\Services\BackupScheduling\BackupScheduleDispatcher` and streams schedules via `cursor()`.
|
||||
- Existing tenant capabilities include:
|
||||
- `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE`
|
||||
- `Capabilities::TENANT_DELETE`
|
||||
- Filament already uses SoftDeletes patterns with `TrashedFilter` and lifecycle actions in `BackupSetResource`.
|
||||
|
||||
157
specs/091-backupschedule-retention-lifecycle/spec.md
Normal file
157
specs/091-backupschedule-retention-lifecycle/spec.md
Normal file
@ -0,0 +1,157 @@
|
||||
# Feature Specification: BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete)
|
||||
|
||||
**Feature Branch**: `091-backupschedule-retention-lifecycle`
|
||||
**Created**: 2026-02-13
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Introduce an enterprise lifecycle for tenant backup schedules: Archive (reversible) as the default destructive action, Restore, and Force Delete (irreversible) as a highly privileged retention/admin tool. Enforce tenant boundary (non-member → 404), capability-first RBAC (member without capability → 403), confirmation for destructive actions, audit events, and ensure archived schedules never execute."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-13
|
||||
|
||||
- Q: When a BackupSchedule has historical runs referencing it, what should Force Delete do? → A: Disallow Force Delete if any historical runs exist (in this repo: `OperationRun` records linked via `BackupSchedule::operationRuns()`).
|
||||
- Q: When restoring an archived schedule, what should happen to its existing enabled/disabled state? → A: Restore only un-archives; enabled/disabled stays unchanged.
|
||||
- Q: Should an archived BackupSchedule record be directly accessible on its View/Edit page for authorized tenant members? → A: Yes — archived records remain accessible.
|
||||
- Q: For BackupSchedule, what should be the primary inspect affordance from the list? → A: Edit page is the primary inspect path.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Archive a schedule safely (Priority: P1)
|
||||
|
||||
As a tenant admin working in the tenant panel (`/admin/t/{tenant}`), I can archive a backup schedule so it no longer runs and is removed from the default list, without permanently deleting it.
|
||||
|
||||
**Why this priority**: Archiving is the safe, reversible way to reduce clutter and stop execution while preserving auditability.
|
||||
|
||||
**Independent Test**: Create an active schedule, archive it (with confirmation), verify it no longer appears in the default list, verify it is visible under an “Archived” view, and verify an audit event exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active backup schedule for the current tenant, **When** a tenant admin archives it (confirmation required), **Then** the schedule becomes archived, is excluded from the default list, and an audit event is recorded.
|
||||
2. **Given** an archived backup schedule, **When** the scheduler/runner evaluates schedules for execution, **Then** archived schedules are ignored and MUST NOT be executed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Restore an archived schedule (Priority: P2)
|
||||
|
||||
As a tenant admin, I can restore an archived backup schedule back to active so it can run again and returns to the default list.
|
||||
|
||||
**Why this priority**: Restoration keeps archiving low-risk and supports operational recovery.
|
||||
|
||||
**Independent Test**: Archive a schedule, restore it, verify it appears in the default list again, and verify an audit event exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an archived backup schedule for the current tenant, **When** a tenant admin restores it (confirmation is optional and MUST NOT be required), **Then** the schedule becomes active, appears in the default list, and an audit event is recorded.
|
||||
2. **Given** an archived schedule that was previously disabled, **When** it is restored, **Then** it remains disabled (restore does not change enabled/disabled state).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Force delete as a privileged retention tool (Priority: P3)
|
||||
|
||||
As a highly privileged tenant admin, I can force delete an archived backup schedule to permanently remove it when governance/retention requires deletion.
|
||||
|
||||
**Why this priority**: Permanent deletion must exist, but must be strictly gated to prevent accidental loss.
|
||||
|
||||
**Independent Test**: Archive a schedule, force delete it (confirmation required), verify it no longer exists, and verify an audit event exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an archived backup schedule for the current tenant, **When** a tenant admin force deletes it (confirmation required), **Then** the schedule is permanently removed and an audit event is recorded.
|
||||
2. **Given** an active backup schedule, **When** a tenant admin attempts to force delete it, **Then** the operation is blocked (not available in UI and rejected server-side).
|
||||
3. **Given** an archived schedule that has one or more historical backup runs, **When** a tenant admin attempts to force delete it, **Then** the operation is blocked with a clear message that historical runs prevent permanent deletion (e.g., “Cannot force delete backup schedule” with an explanation that schedules referenced by historical runs cannot be removed).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Archiving an already archived schedule is idempotent (no state change) and returns a success response with a clear “already archived” message; no audit event is written.
|
||||
- Restoring an already active schedule is idempotent (no state change) and returns a success response with a clear “already active” message; no audit event is written.
|
||||
- If a schedule is archived after a run is queued but before it executes, the queued run is skipped (no backup is performed) and is recorded as “skipped/blocked” with a clear reason (“Schedule archived”).
|
||||
- Restoring a schedule MUST NOT implicitly enable or disable it.
|
||||
- Force delete is permitted only for archived schedules, even if a crafted request is sent.
|
||||
- Force delete is blocked when it would break historical traceability or violate data integrity (clear, user-friendly message).
|
||||
- Force delete is blocked when historical runs exist for the schedule.
|
||||
- Cross-tenant access to a schedule MUST be deny-as-not-found (404 semantics), not a leak of existence.
|
||||
- Non-member / not entitled → 404; member without capability → 403.
|
||||
- Concurrent lifecycle changes keep consistent state and record audit entries with actor/outcome.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation,
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
**Constitution alignment (Action Surfaces):** If this feature adds or modifies any admin UI Resource / RelationManager / Page,
|
||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
|
||||
**Action Surface Contract status:** Satisfied. Primary inspect/edit affordance is present; destructive actions are grouped, capability-gated, and confirmed.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST support an archived lifecycle state for tenant-scoped backup schedules.
|
||||
- **FR-002**: The default schedule list view MUST show active schedules only (archived excluded) and MUST provide a way to view archived schedules.
|
||||
- **FR-003**: Archive MUST require confirmation; restore confirmation is optional and MUST NOT be required.
|
||||
- **FR-004**: Force delete MUST require confirmation and MUST be permitted only after a schedule is archived.
|
||||
- **FR-004a**: Restore MUST only reverse archival and MUST NOT change the schedule’s enabled/disabled state.
|
||||
- **FR-005**: Archived schedules MUST NOT be eligible for execution by any scheduler/runner.
|
||||
- **FR-006**: Each lifecycle mutation (archive, restore, force delete) MUST write an audit event including tenant context, actor, target schedule identity, action identifier, timestamp, and outcome.
|
||||
- **FR-007**: Authorization MUST be enforced on the tenant plane (`/admin/t/{tenant}`) using the canonical capability registry:
|
||||
- cross-tenant access → 404 (deny-as-not-found)
|
||||
- non-member/not entitled → 404
|
||||
- member without capability → 403
|
||||
- **FR-008**: Bulk destructive actions for this feature MUST NOT be provided (Bulk Actions: None).
|
||||
- **FR-009**: Force delete MUST be blocked when it would break historical traceability or violate data integrity, with a clear, user-friendly explanation.
|
||||
- **FR-010**: Audit action identifiers MUST be stable and use these event names:
|
||||
- backup_schedule.archived
|
||||
- backup_schedule.restored
|
||||
- backup_schedule.force_deleted
|
||||
- **FR-011**: Force delete MUST be disallowed when any historical runs exist for the schedule.
|
||||
- **FR-012**: Archived schedules MUST remain directly accessible on their record pages for authorized tenant members (show archived state and offer Restore / Force Delete per RBAC).
|
||||
- **FR-013**: The primary inspect affordance from the schedules list MUST open the Edit page (no dedicated View page is required).
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any admin UI Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| BackupSchedule Resource | tenant panel (`/admin/t/{tenant}`) | Create Schedule | Primary inspect opens Edit page | Edit, More | None | Create Schedule | Archive / Restore / Force Delete | Save, Cancel | Yes | Default list: active only. Filter/view for “Archived”. Archived records remain directly accessible for authorized members (show “Archived” state + offer Restore / Force Delete per RBAC). “More” contains Archive/Restore/Force Delete. Archive + Force Delete require confirmation. Force Delete only visible/enabled when record is archived AND user has force-delete capability. Non-member → 404; member without capability → 403 (UI disabled with guidance). |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **BackupSchedule**: A tenant-scoped schedule definition that can be active or archived; archived schedules must not run.
|
||||
- **OperationRun**: A historical record of a backup execution; must remain reviewable and attributable even if the schedule is archived or later permanently removed.
|
||||
- **Audit Event**: An immutable record describing who performed archive/restore/force delete, in which tenant context, and when.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Tenant admins can archive a schedule in under 30 seconds, including required confirmation.
|
||||
- **SC-002**: Archived schedules are never executed by the scheduler/runner.
|
||||
- **SC-003**: Audit events exist for 100% of lifecycle mutations (archive, restore, force delete) and are tenant-scoped.
|
||||
- **SC-004**: The default schedule list remains uncluttered (archived excluded by default) while archived items remain discoverable.
|
||||
|
||||
177
specs/091-backupschedule-retention-lifecycle/tasks.md
Normal file
177
specs/091-backupschedule-retention-lifecycle/tasks.md
Normal file
@ -0,0 +1,177 @@
|
||||
---
|
||||
|
||||
description: "Executable task list for Spec 091 implementation"
|
||||
---
|
||||
|
||||
# Tasks: BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete)
|
||||
|
||||
**Input**: Design documents from `/specs/091-backupschedule-retention-lifecycle/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) for runtime behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm baseline assumptions and align with repo conventions before changing runtime behavior.
|
||||
|
||||
- [X] T001 Confirm SoftDeletes + lifecycle conventions in app/Filament/Resources/BackupSetResource.php
|
||||
- [X] T002 Confirm RBAC-UX helper usage patterns in app/Support/Rbac/UiEnforcement.php
|
||||
- [X] T003 Confirm BackupSchedule scheduling flow touchpoints in app/Services/BackupScheduling/BackupScheduleDispatcher.php and app/Jobs/RunBackupScheduleJob.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Introduce the archived lifecycle state at the data/model layer.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Add SoftDeletes to app/Models/BackupSchedule.php
|
||||
- [X] T005 Create migration to add deleted_at to backup_schedules in database/migrations/ (new migration file)
|
||||
- [X] T006 [P] Update BackupSchedule model query expectations/tests to account for soft-deleted records in tests/Feature/BackupScheduling/BackupScheduleCrudTest.php
|
||||
|
||||
**Checkpoint**: `BackupSchedule` supports archived state (`deleted_at`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Archive a schedule safely (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Tenant admins can archive schedules (soft delete) with confirmation; archived schedules are excluded from the default list and MUST never execute.
|
||||
|
||||
**Independent Test**: Create an active schedule, archive it, verify it disappears from the default list, is visible under an “Archived” filter, audit entry exists, and scheduler/job do not dispatch/execute archived schedules.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T007 [P] [US1] Add archive lifecycle tests in tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
- [X] T008 [P] [US1] Add scheduler safety test (archived schedules not dispatched) in tests/Feature/BackupScheduling/DispatchIdempotencyTest.php
|
||||
- [X] T009 [P] [US1] Add job safety test (archived schedule skipped) in tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php
|
||||
- [X] T009 [P] [US1] Add job safety test (archived schedule skipped) in tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php (assert `OperationRun` is `completed` with outcome `blocked`, `summary_counts.skipped=1`, and `failure_summary` includes `code=schedule_archived`)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T010 [US1] Add “Archived” filter to BackupSchedule table in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T011 [US1] Remove/disable bulk destructive actions for schedules in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T012 [US1] Replace hard-delete semantics with “Archive” action (soft delete) in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T013 [US1] Enforce RBAC-UX (non-member→404; member missing capability→403) for archive action using app/Support/Rbac/UiEnforcement.php in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T014 [US1] Emit audit event backup_schedule.archived via app/Services/Intune/AuditLogger.php from app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T015 [US1] Ensure dispatcher excludes archived schedules in app/Services/BackupScheduling/BackupScheduleDispatcher.php
|
||||
- [X] T016 [US1] Add execution-time guard to skip archived schedules in app/Jobs/RunBackupScheduleJob.php
|
||||
- [X] T016 [US1] Add execution-time guard to skip archived schedules in app/Jobs/RunBackupScheduleJob.php (set `OperationRun` to `completed` + outcome `blocked`, `summary_counts.skipped=1`, `failure_summary.code=schedule_archived`, and emit `backup_schedule.run_skipped` with `reason=schedule_archived`)
|
||||
|
||||
**Checkpoint**: Archive works end-to-end; archived schedules never execute.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Restore an archived schedule (Priority: P2)
|
||||
|
||||
**Goal**: Tenant admins can restore archived schedules (no confirmation required) without changing enabled/disabled state.
|
||||
|
||||
**Independent Test**: Archive a schedule, restore it, verify it returns to the default list, `is_enabled` is unchanged, and audit entry exists.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T017 [P] [US2] Add restore lifecycle tests (no confirmation required; is_enabled unchanged) in tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T018 [US2] Add “Restore” action (only for archived records) in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T019 [US2] Enforce RBAC-UX for restore action using app/Support/Rbac/UiEnforcement.php in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T020 [US2] Emit audit event backup_schedule.restored via app/Services/Intune/AuditLogger.php from app/Filament/Resources/BackupScheduleResource.php
|
||||
|
||||
**Checkpoint**: Restore is functional and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Force delete as a privileged retention tool (Priority: P3)
|
||||
|
||||
**Goal**: Highly privileged tenant admins can force delete archived schedules with confirmation, but only when no historical runs exist.
|
||||
|
||||
**Independent Test**: Archive a schedule, attempt force delete without `TENANT_DELETE` (403), attempt force delete with historical runs (blocked), force delete with no historical runs (success), and audit entry exists.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T021 [P] [US3] Add force delete tests (capability gated; only archived; blocked when historical runs exist) in tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T022 [US3] Add “Force delete” action (only for archived records; requires confirmation) in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T023 [US3] Enforce RBAC-UX for force delete using app/Support/Rbac/UiEnforcement.php in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T024 [US3] Block force delete when historical runs exist using BackupSchedule::operationRuns() in app/Models/BackupSchedule.php and enforce in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T024 [US3] Block force delete when historical runs exist using BackupSchedule::operationRuns() in app/Models/BackupSchedule.php and enforce in app/Filament/Resources/BackupScheduleResource.php (danger notification: title “Cannot force delete backup schedule”, body “Backup schedules referenced by historical runs cannot be removed.”; do not delete; do not emit `backup_schedule.force_deleted`)
|
||||
- [X] T025 [US3] Emit audit event backup_schedule.force_deleted via app/Services/Intune/AuditLogger.php from app/Filament/Resources/BackupScheduleResource.php
|
||||
|
||||
**Checkpoint**: Force delete is safe (blocked when history exists) and audited.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Ensure contract compliance, update impacted tests, and validate via quickstart.
|
||||
|
||||
- [X] T026 [P] Update bulk delete test to match “no bulk destructive actions” rule in tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php
|
||||
- [X] T027 Ensure BackupSchedule list inspect affordance opens Edit page in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T028 Ensure “max 2 visible row actions; everything else in More ActionGroup” in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T029 Run formatting in repo using vendor/bin/sail bin pint --dirty
|
||||
- [X] T030 Run focused test suite using vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling
|
||||
- [ ] T031 Run Spec 091 manual checklist from specs/091-backupschedule-retention-lifecycle/quickstart.md
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; BLOCKS all user stories.
|
||||
- **User Stories (Phase 3–5)**: Depend on Foundational completion.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (Archive)**: First; enables “Archived” state and execution safety.
|
||||
- **US2 (Restore)**: Depends on US1 (requires archived records to exist).
|
||||
- **US3 (Force delete)**: Depends on US1 (force delete only applies to archived records).
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1 (after Phase 2)
|
||||
|
||||
- T007 (lifecycle tests), T008 (dispatcher safety test), and T009 (job safety test) can be written in parallel.
|
||||
- T015 (dispatcher exclusion) and T016 (job guard) can be implemented in parallel.
|
||||
|
||||
### US3 (after US1)
|
||||
|
||||
- T021 (force delete tests) can be written while UI action scaffolding is prepared (T022–T023).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 Only)
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Implement US1 (archive + execution safety + audit) and pass US1 tests.
|
||||
3. Validate via Phase 6 tasks T029–T031.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- Add US2 (restore) once US1 is stable.
|
||||
- Add US3 (force delete) last, with strict capability gating and history blocking.
|
||||
|
||||
---
|
||||
|
||||
## Additions (Consistency + Constitution Coverage)
|
||||
|
||||
**Purpose**: Close remaining gaps found by the consistency analysis (terminology, FR-012 coverage, and explicit server-side RBAC enforcement + tests).
|
||||
|
||||
- [X] T032 [US1] Allow editing archived schedules (SoftDeletes) in app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php
|
||||
- [X] T033 [P] [US1] Add test asserting an authorized tenant member can open Edit for a soft-deleted schedule in tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
- [X] T034 [US1] Enforce server-side authorization (Policy/Gate) for Archive action in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T035 [US2] Enforce server-side authorization (Policy/Gate) for Restore action in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T036 [US3] Enforce server-side authorization (Policy/Gate) for Force delete action in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T037 [P] [US1] Add explicit RBAC-UX tests for 404 vs 403 semantics in tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php
|
||||
- [X] T038 [P] [US1] Add idempotency tests for Archive/Restore (already archived / already active) in tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
|
||||
@ -5,12 +5,12 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('owner can bulk delete backup schedules', function () {
|
||||
test('backup schedules list does not expose a bulk destructive action for owners', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Delete A',
|
||||
'name' => 'No bulk delete',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
@ -21,31 +21,18 @@
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduleB = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Delete B',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
->assertTableBulkActionDoesNotExist('bulk_delete')
|
||||
->assertTableBulkActionExists('bulk_run_now')
|
||||
->assertTableBulkActionExists('bulk_retry');
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count())
|
||||
->toBe(0);
|
||||
expect(BackupSchedule::query()->whereKey($schedule->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('operator cannot bulk delete backup schedules', function () {
|
||||
test('backup schedules remain unchanged when bulk destructive action is absent', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
@ -77,13 +64,9 @@
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
try {
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB]));
|
||||
} catch (\Throwable) {
|
||||
// Action should be hidden/blocked for operator users.
|
||||
}
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->assertTableBulkActionDoesNotExist('bulk_delete');
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count())
|
||||
->toBe(2);
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->pluck('id')->all())
|
||||
->toContain((int) $scheduleA->id, (int) $scheduleB->id);
|
||||
});
|
||||
|
||||
@ -127,3 +127,48 @@
|
||||
$schedule->refresh();
|
||||
expect($schedule->name)->toBe('Daily at 11');
|
||||
});
|
||||
|
||||
test('soft-deleted schedules are excluded from default schedule queries', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$active = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Active schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '03:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$archived = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Archived schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '04:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$archived->delete();
|
||||
|
||||
expect(BackupSchedule::query()->pluck('id')->all())
|
||||
->toBe([(int) $active->getKey()]);
|
||||
|
||||
expect(BackupSchedule::withTrashed()->pluck('id')->all())
|
||||
->toContain((int) $active->getKey(), (int) $archived->getKey());
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee('Active schedule')
|
||||
->assertDontSee('Archived schedule');
|
||||
});
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 404 for non-members trying to access schedule lifecycle pages', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BackupScheduleResource::getUrl('index', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('treats members without manage capability as forbidden for archive and restore', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Lifecycle auth readonly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->assertTableActionDisabled('archive', $schedule)
|
||||
->assertTableActionExists('archive', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $schedule)
|
||||
->callTableAction('archive', $schedule);
|
||||
|
||||
expect(function () use ($user, $schedule): void {
|
||||
Gate::forUser($user)->authorize('delete', $schedule);
|
||||
})->toThrow(AuthorizationException::class);
|
||||
|
||||
$schedule->delete();
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->assertTableActionDisabled('restore', BackupSchedule::withTrashed()->findOrFail($schedule->id))
|
||||
->assertTableActionExists('restore', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), BackupSchedule::withTrashed()->findOrFail($schedule->id));
|
||||
|
||||
expect(function () use ($user, $schedule): void {
|
||||
Gate::forUser($user)->authorize('restore', $schedule->fresh());
|
||||
})->toThrow(AuthorizationException::class);
|
||||
});
|
||||
|
||||
it('treats members without tenant delete capability as forbidden for force delete', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Force delete forbidden',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
$schedule->delete();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->assertTableActionDisabled('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id))
|
||||
->assertTableActionExists('forceDelete', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), BackupSchedule::withTrashed()->findOrFail($schedule->id));
|
||||
|
||||
expect(function () use ($user, $schedule): void {
|
||||
Gate::forUser($user)->authorize('forceDelete', $schedule->fresh());
|
||||
})->toThrow(AuthorizationException::class);
|
||||
});
|
||||
206
tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
Normal file
206
tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
Normal file
@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeBackupScheduleForLifecycle(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
|
||||
{
|
||||
return BackupSchedule::query()->create(array_merge([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly lifecycle',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('archives schedules, hides them from default list, and shows them in archived filter', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Archive me']);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('archive', $schedule)
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$schedule->refresh();
|
||||
expect($schedule->trashed())->toBeTrue();
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'backup_schedule.archived',
|
||||
'resource_type' => 'backup_schedule',
|
||||
'resource_id' => (string) $schedule->id,
|
||||
]);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->assertCanNotSeeTableRecords([$schedule]);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->assertCanSeeTableRecords([BackupSchedule::withTrashed()->findOrFail($schedule->id)]);
|
||||
});
|
||||
|
||||
it('restores archived schedules without changing enabled state', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, [
|
||||
'name' => 'Restore me',
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
$schedule->delete();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->assertTableActionExists('restore', function ($action): bool {
|
||||
return $action->isConfirmationRequired() === false;
|
||||
}, BackupSchedule::withTrashed()->findOrFail($schedule->id))
|
||||
->callTableAction('restore', BackupSchedule::withTrashed()->findOrFail($schedule->id))
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$schedule->refresh();
|
||||
expect($schedule->trashed())->toBeFalse();
|
||||
expect((bool) $schedule->is_enabled)->toBeFalse();
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'backup_schedule.restored',
|
||||
'resource_type' => 'backup_schedule',
|
||||
'resource_id' => (string) $schedule->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows force delete only to users with tenant delete capability', function () {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Protected force delete']);
|
||||
$schedule->delete();
|
||||
|
||||
$this->actingAs($manager);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
expect(function () use ($manager, $schedule): void {
|
||||
Gate::forUser($manager)->authorize('forceDelete', $schedule);
|
||||
})->toThrow(AuthorizationException::class);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->assertTableActionDisabled('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id));
|
||||
|
||||
expect(BackupSchedule::withTrashed()->whereKey($schedule->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks force delete when historical runs exist', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Blocked force delete']);
|
||||
$schedule->delete();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'type' => 'backup_schedule_run',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => ['backup_schedule_id' => (int) $schedule->id],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->callTableAction('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id))
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
expect(BackupSchedule::withTrashed()->whereKey($schedule->id)->exists())->toBeTrue();
|
||||
|
||||
$this->assertDatabaseMissing('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'backup_schedule.force_deleted',
|
||||
'resource_id' => (string) $schedule->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('force deletes archived schedules when no historical runs exist', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Delete me forever']);
|
||||
$schedule->delete();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->callTableAction('forceDelete', BackupSchedule::withTrashed()->findOrFail($schedule->id))
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
expect(BackupSchedule::withTrashed()->whereKey($schedule->id)->exists())->toBeFalse();
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'backup_schedule.force_deleted',
|
||||
'resource_type' => 'backup_schedule',
|
||||
'resource_id' => (string) $schedule->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows editing archived schedules for authorized members', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = makeBackupScheduleForLifecycle($tenant, ['name' => 'Archived editable']);
|
||||
$schedule->delete();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()])
|
||||
->fillForm([
|
||||
'name' => 'Archived edited',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$schedule->refresh();
|
||||
expect($schedule->name)->toBe('Archived edited');
|
||||
expect($schedule->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('enforces state-idempotent lifecycle actions in the table surface', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$active = makeBackupScheduleForLifecycle($tenant, ['name' => 'Active state']);
|
||||
$archived = makeBackupScheduleForLifecycle($tenant, ['name' => 'Archived state']);
|
||||
$archived->delete();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->assertTableActionHidden('restore', $active)
|
||||
->assertTableActionHidden('forceDelete', $active)
|
||||
->assertTableActionHidden('archive', BackupSchedule::withTrashed()->findOrFail($archived->id));
|
||||
|
||||
expect((bool) $active->fresh()->trashed())->toBeFalse();
|
||||
expect((bool) BackupSchedule::withTrashed()->findOrFail($archived->id)->trashed())->toBeTrue();
|
||||
});
|
||||
@ -102,3 +102,40 @@
|
||||
expect($schedule->next_run_at)->not->toBeNull();
|
||||
expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00');
|
||||
});
|
||||
|
||||
it('does not dispatch archived schedules', function () {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Archived daily 10:00',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
$schedule->delete();
|
||||
|
||||
Bus::fake();
|
||||
|
||||
$dispatcher = app(BackupScheduleDispatcher::class);
|
||||
$result = $dispatcher->dispatchDue([$tenant->external_id]);
|
||||
|
||||
expect($result['created_runs'])->toBe(0)
|
||||
->and($result['scanned_schedules'])->toBe(0)
|
||||
->and(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())->toBe(0);
|
||||
|
||||
Bus::assertNotDispatched(RunBackupScheduleJob::class);
|
||||
});
|
||||
|
||||
@ -160,6 +160,78 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
Bus::assertNotDispatched(ApplyBackupScheduleRetentionJob::class);
|
||||
});
|
||||
|
||||
it('marks runs as blocked when the schedule is archived', function () {
|
||||
Bus::fake();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Archived schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
$schedule->delete();
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule_run',
|
||||
inputs: ['backup_schedule_id' => (int) $schedule->id],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(PolicyTypeResolver::class),
|
||||
app(ScheduleTimeService::class),
|
||||
app(AuditLogger::class),
|
||||
app(RunErrorMapper::class),
|
||||
);
|
||||
|
||||
$schedule->refresh();
|
||||
expect($schedule->last_run_status)->toBe('skipped');
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('blocked');
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
expect($operationRun->failure_summary)->toMatchArray([
|
||||
[
|
||||
'code' => 'schedule_archived',
|
||||
'reason_code' => 'unknown_error',
|
||||
'message' => 'Schedule is archived; run will not execute.',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'backup_schedule.run_skipped',
|
||||
]);
|
||||
|
||||
Bus::assertNotDispatched(ApplyBackupScheduleRetentionJob::class);
|
||||
});
|
||||
|
||||
it('fails fast when operation run context is not passed into the job', function () {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
class FakeGraphClientForDeps implements GraphClientInterface
|
||||
{
|
||||
@ -65,7 +67,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'include_dependencies' => true,
|
||||
]);
|
||||
|
||||
expect($run->status)->toBe('success');
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
|
||||
// 2 assigned_to + 2 scoped_by = 4
|
||||
@ -185,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'include_dependencies' => true,
|
||||
]);
|
||||
|
||||
$warnings = $run->error_context['warnings'] ?? null;
|
||||
$warnings = $run->context['result']['error_context']['warnings'] ?? null;
|
||||
expect($warnings)->toBeArray()->toHaveCount(1);
|
||||
expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference');
|
||||
|
||||
@ -358,7 +361,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
'include_dependencies' => true,
|
||||
]);
|
||||
|
||||
expect($run->status)->toBe('success');
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
|
||||
expect($edges->where('relationship_type', 'scoped_by'))->toHaveCount(1);
|
||||
|
||||
@ -15,16 +15,9 @@
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$thrown = null;
|
||||
|
||||
try {
|
||||
Livewire::test(ListFindings::class)
|
||||
->callTableAction('acknowledge', $finding);
|
||||
} catch (Throwable $exception) {
|
||||
$thrown = $exception;
|
||||
}
|
||||
|
||||
expect($thrown)->not->toBeNull();
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertTableActionDisabled('acknowledge', $finding)
|
||||
->callTableAction('acknowledge', $finding);
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
@ -5,10 +5,8 @@
|
||||
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
@ -73,7 +71,6 @@
|
||||
->callAction('sync_groups');
|
||||
|
||||
Queue::assertNotPushed(EntraGroupSyncJob::class);
|
||||
expect(EntraGroupSyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -103,7 +100,6 @@
|
||||
->callTableAction('runNow', $schedule);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -133,6 +129,5 @@
|
||||
->callTableAction('retry', $schedule);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse();
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -50,6 +50,16 @@ protected static function booted(): void
|
||||
$builder->where('type', 'inventory_sync');
|
||||
});
|
||||
|
||||
static::addGlobalScope('legacy_statuses_only', function (Builder $builder): void {
|
||||
$builder->whereIn('status', [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_SKIPPED,
|
||||
]);
|
||||
});
|
||||
|
||||
static::saving(function (self $run): void {
|
||||
if (! filled($run->type)) {
|
||||
$run->type = 'inventory_sync';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user