diff --git a/candidate/modules/v1/controllers/AccountController.php b/candidate/modules/v1/controllers/AccountController.php index 1c2a07b2..8247a787 100644 --- a/candidate/modules/v1/controllers/AccountController.php +++ b/candidate/modules/v1/controllers/AccountController.php @@ -1378,7 +1378,12 @@ public function actionUpdateCivilPhotoBack() { ]; } - $model->updateCivilId('back'); + if (!$model->updateCivilId('back')) { + return [ + 'operation' => 'error', + 'message' => $model->getErrors() + ]; + } //reset to remove old id's data $model->candidate_civil_expiry_date = null; @@ -1391,6 +1396,8 @@ public function actionUpdateCivilPhotoBack() { ]; } + $oldCivilIdCleanupComplete = $model->deletePendingCivilIdFiles('back'); + return [ 'operation' => 'success', "candidate_civil_photo_back" => $model->candidate_civil_photo_back, @@ -1399,6 +1406,7 @@ public function actionUpdateCivilPhotoBack() { "candidate_civil_id" => $model->candidate_civil_id, 'civilExpired' => $model->candidate_civil_expiry_date && (strtotime($model->candidate_civil_expiry_date) < strtotime(date('Y-m-d'))), + 'civil_photo_cleanup_pending' => !$oldCivilIdCleanupComplete, 'message' => Yii::t('candidate', 'Civil Photo Back Uploaded Successfully') ]; @@ -1430,7 +1438,12 @@ public function actionUpdateCivilPhotoFront() { ]; } - $model->updateCivilId('front'); + if (!$model->updateCivilId('front')) { + return [ + 'operation' => 'error', + 'message' => $model->getErrors() + ]; + } //reset to remove old id's data $model->candidate_civil_expiry_date = null; @@ -1443,6 +1456,8 @@ public function actionUpdateCivilPhotoFront() { ]; } + $oldCivilIdCleanupComplete = $model->deletePendingCivilIdFiles('front'); + return [ 'operation' => 'success', @@ -1452,6 +1467,7 @@ public function actionUpdateCivilPhotoFront() { "candidate_civil_id" => $model->candidate_civil_id, 'civilExpired' => $model->candidate_civil_expiry_date && (strtotime($model->candidate_civil_expiry_date) < strtotime(date('Y-m-d'))), + 'civil_photo_cleanup_pending' => !$oldCivilIdCleanupComplete, 'message' => Yii::t('candidate', 'Civil Photo Front Uploaded Successfully') ]; @@ -1524,22 +1540,30 @@ public function actionUpdateCivilIdExpiryDate() { $candidate_civil_id = Yii::$app->request->getBodyParam('civil_id'); $candidate_civil_expiry_date = Yii::$app->request->getBodyParam('civil_expiry_date'); - // Input validation: never raw 500 for invalid client payloads. - if (!is_string($candidate_civil_id) || trim($candidate_civil_id) === '') { + if (!is_scalar($candidate_civil_id) || trim((string) $candidate_civil_id) === '') { return [ 'operation' => 'error', 'message' => Yii::t('candidate', 'Civil ID is required.'), ]; } - if (!is_string($candidate_civil_expiry_date) || trim($candidate_civil_expiry_date) === '') { + $candidate_civil_id = trim((string) $candidate_civil_id); + + if (!preg_match('/^\d{12}$/', $candidate_civil_id)) { + return [ + 'operation' => 'error', + 'message' => Yii::t('candidate', 'Civil ID must be 12 digit number.'), + ]; + } + + if (!is_scalar($candidate_civil_expiry_date) || trim((string) $candidate_civil_expiry_date) === '') { return [ 'operation' => 'error', 'message' => Yii::t('candidate', 'Civil ID expiry date is required.'), ]; } - $expiryDt = $this->parseStrictCivilExpiryDateUtc(trim($candidate_civil_expiry_date)); + $expiryDt = $this->parseStrictCivilExpiryDateUtc(trim((string) $candidate_civil_expiry_date)); if ($expiryDt === null) { return [ 'operation' => 'error', @@ -1553,16 +1577,13 @@ public function actionUpdateCivilIdExpiryDate() { $candidate->scenario = 'updateCivilExpiryDateAndCivilID'; try { - if (!$candidate->save()) { return [ 'operation' => 'error', - 'message' => $candidate->errors, + 'message' => $candidate->getErrors(), ]; } - } catch (\Throwable $e) { - Yii::error([ 'action' => 'actionUpdateCivilIdExpiryDate', 'candidate_id' => $candidate->candidate_id, diff --git a/candidate/tests/functional/AccountCest.php b/candidate/tests/functional/AccountCest.php index b25e116a..985ad57b 100644 --- a/candidate/tests/functional/AccountCest.php +++ b/candidate/tests/functional/AccountCest.php @@ -183,6 +183,51 @@ public function tryUpdateCivilId(FunctionalTester $I) $I->seeResponseCodeIs(HttpCode::OK); // 200 $I->seeResponseContainsJson([ 'operation' => 'success','message' => 'Candidate Civil ID Info Updated Successfully']); } + + public function tryUpdateCivilIdExpiryDateRequiresCivilId(FunctionalTester $I) + { + $I->amGoingTo('reject empty civil id while updating civil id and expiry date'); + $I->sendPOST('v1/account/update-civil-id-expiry-date', [ + 'civil_id' => '', + 'civil_expiry_date' => date('Y-m-d', strtotime('+1 month')), + ]); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseContainsJson([ + 'operation' => 'error', + 'message' => 'Civil ID is required', + ]); + } + + /** + * Verifies non-empty but invalid Civil IDs are rejected before saving. + */ + public function tryUpdateCivilIdExpiryDateRejectsShortCivilId(FunctionalTester $I) + { + $I->amGoingTo('reject short civil id while updating civil id and expiry date'); + $I->sendPOST('v1/account/update-civil-id-expiry-date', [ + 'civil_id' => '70', + 'civil_expiry_date' => date('Y-m-d', strtotime('+1 month')), + ]); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseContainsJson([ + 'operation' => 'error', + 'message' => 'Civil ID must be 12 digits', + ]); + } + + public function tryUpdateCivilIdExpiryDateRejectsInvalidDate(FunctionalTester $I) + { + $I->amGoingTo('reject invalid civil expiry date while updating civil id and expiry date'); + $I->sendPOST('v1/account/update-civil-id-expiry-date', [ + 'civil_id' => '123456789012', + 'civil_expiry_date' => 'not-a-date', + ]); + $I->seeResponseCodeIs(HttpCode::OK); + $I->seeResponseContainsJson([ + 'operation' => 'error', + 'message' => 'Invalid civil expiry date', + ]); + } public function tryUpdateNationality(FunctionalTester $I) { diff --git a/common/components/S3ResourceManager.php b/common/components/S3ResourceManager.php index 608a407e..505939b0 100644 --- a/common/components/S3ResourceManager.php +++ b/common/components/S3ResourceManager.php @@ -166,25 +166,64 @@ public function delete($name) } /** - * Checks whether a file exists or not. This method only works for public resources, private resources will throw - * a 403 error exception. + * Returns the bucket/key pair for a stored object reference. + * Supports raw keys and S3 object URLs. + * @param string $filenameOrUrl + * @return array + */ + protected function resolveObjectLocation($filenameOrUrl) + { + $bucket = $this->bucket; + $key = ltrim((string) $filenameOrUrl, '/'); + + if (strpos((string) $filenameOrUrl, 'http') !== false) { + $parts = parse_url($filenameOrUrl); + $key = isset($parts['path']) ? ltrim($parts['path'], '/') : ''; + + if (!empty($parts['host']) && preg_match('/^([^.]+)\.s3[.-]/', $parts['host'], $matches)) { + $bucket = $matches[1]; + } elseif (!empty($parts['host']) && preg_match('/^s3[.-]/', $parts['host']) && $key !== '') { + $segments = explode('/', $key, 2); + $bucket = $segments[0] ?: $bucket; + $key = $segments[1] ?? ''; + } + + $key = rawurldecode($key); + } + + return [$bucket, $key]; + } + + /** + * Returns S3 object metadata. + * @param string $filenameOrUrl + * @return \Aws\Result + */ + public function headObject($filenameOrUrl) + { + [$bucket, $key] = $this->resolveObjectLocation($filenameOrUrl); + + return $this->getClient()->headObject([ + 'Bucket' => $bucket, + 'Key' => $key, + ]); + } + + /** + * Checks whether a file exists or not using S3 metadata. * @param string $filenameOrUrl the name or url of the file * @return boolean */ public function fileExists($filenameOrUrl) { - $isUrl = false; - if (strpos($filenameOrUrl, 'http') !== false) { - $isUrl = true; - } - - $http = new \GuzzleHttp\Client(['base_uri' => $isUrl ? $filenameOrUrl : $this->getUrl($filenameOrUrl)]); try { - $response = $http->request('HEAD'); + $this->headObject($filenameOrUrl); + return true; + } catch (AwsException $e) { + return false; } catch (\Exception $e) { return false; } - return $response->getStatusCode() == 200; } /** diff --git a/common/models/Candidate.php b/common/models/Candidate.php index cca7135f..fe69ba7b 100644 --- a/common/models/Candidate.php +++ b/common/models/Candidate.php @@ -114,6 +114,11 @@ class Candidate extends \yii\db\ActiveRecord implements \yii\web\IdentityInterfa const NOT_HAVE_DRIVING_LICENCE = 2; public $pendingProfile = []; + + /** + * @var array Civil ID S3 keys staged for cleanup after the DB row is saved. + */ + private $pendingCivilIdDeletionKeys = []; // Array of attribute names and folder names to store them in the permanent bucket public $FILE_ATTRIBUTES = [ @@ -349,10 +354,10 @@ public function scenarios() { $scenarios['tmpProfilePhoto'] = ['profile_photo', 'is_incomplete_profile']; $scenarios['updateCivilPhotoBack'] = ['candidate_civil_photo_back', "candidate_civil_expiry_date", - "candidate_civil_id", 'is_incomplete_profile']; + "candidate_civil_id", "candidate_civil_need_verification", 'is_incomplete_profile']; $scenarios['updateCivilPhotoFront'] = ['candidate_civil_photo_front', "candidate_civil_expiry_date", - "candidate_civil_id", 'is_incomplete_profile']; + "candidate_civil_id", "candidate_civil_need_verification", 'is_incomplete_profile']; $scenarios['updateNationality'] = ['country_id', 'is_incomplete_profile']; @@ -2764,41 +2769,90 @@ public static function classifyS3DeleteThrowable(\Throwable $e): string * delete file from aws * @param string $type * @param string $side - * @return false + * @param bool $bestEffort + * @return bool */ - public function deleteFile($type = 'resume', $side = 'front') { + private function resolveStoredFileAttribute($type = 'resume', $side = 'front') + { + if ($type === 'civil-id') { + return $side === 'back' ? 'candidate_civil_photo_back' : 'candidate_civil_photo_front'; + } - $file = null; + return 'candidate_resume'; + } - $errorAttribute = 'candidate_resume'; - if ($type === 'civil-id') { - $errorAttribute = ($side === 'back') - ? 'candidate_civil_photo_back' - : 'candidate_civil_photo_front'; + /** + * @param string $type + * @param string $side + * @param bool $useOldAttributes + * @return string|null + */ + private function resolveStoredFileKey($type = 'resume', $side = 'front', $useOldAttributes = true) + { + $attribute = $this->resolveStoredFileAttribute($type, $side); + $fileName = $useOldAttributes ? ($this->oldAttributes[$attribute] ?? null) : $this->$attribute; + + if (!$fileName) { + return null; } - try { - if (isset($this->oldPrimaryKey)) { - - if ($type == 'resume' && isset($this->oldAttributes['candidate_resume'])) { - $file = "candidate-resume/" . $this->oldAttributes['candidate_resume']; - } else if ($type == 'civil-id' && $side == 'front' && isset($this->oldAttributes['candidate_civil_photo_front'])) { - $file = self::normalizeCivilIdPermanentS3Key($this->oldAttributes['candidate_civil_photo_front']); - } else if ($type == 'civil-id' && $side == 'back' && isset($this->oldAttributes['candidate_civil_photo_back'])) { - $file = self::normalizeCivilIdPermanentS3Key($this->oldAttributes['candidate_civil_photo_back']); - } + $directory = $type === 'civil-id' ? 'photos' : 'candidate-resume'; + $fileName = ltrim((string) $fileName, '/'); - if ($file) { - Yii::$app->resourceManager->delete($file); - } - } + if (preg_match('/^https?:\/\//i', $fileName)) { + $parts = parse_url($fileName); + $fileName = isset($parts['path']) ? ltrim(rawurldecode($parts['path']), '/') : ''; + } + + if ($fileName === '') { + return null; + } + + if (strpos($fileName, '/') !== false) { + return $fileName; + } + + return $directory . '/' . $fileName; + } + + /** + * @param string $operation + * @param string $attribute + * @param string|null $fileKey + * @param \Throwable $exception + * @param array $extra + * @return void + */ + private function logS3FileFailure($operation, $attribute, $fileKey, \Throwable $exception, array $extra = []) + { + Yii::error(array_merge([ + 'message' => sprintf('Failed to %s candidate file', $operation), + 'operation' => $operation, + 'route' => Yii::$app->requestedRoute, + 'candidate_id' => $this->candidate_id, + 'attribute' => $attribute, + 'filename' => $fileKey ? basename($fileKey) : null, + 'file_key' => $fileKey, + 'error' => $exception->getMessage(), + ], $extra), 'candidate'); + } + + public function deleteFile($type = 'resume', $side = 'front', $bestEffort = false) { + $attribute = $this->resolveStoredFileAttribute($type, $side); + $fileKey = $this->resolveStoredFileKey($type, $side, true); + + if (!$fileKey || !isset($this->oldPrimaryKey)) { + return true; + } + + try { + Yii::$app->resourceManager->delete($fileKey); return true; } catch (\Throwable $e) { + $this->logS3FileFailure('delete', $attribute, $fileKey, $e); - // Missing-object deletes (e.g. the legacy PressureDelete3Days lifecycle - // already removed the object) must not break remove/replace flows. $reason = ($type === 'civil-id') ? self::classifyS3DeleteThrowable($e) : 's3_delete_failed'; @@ -2809,98 +2863,118 @@ public function deleteFile($type = 'resume', $side = 'front') { 'type' => $type, 'side' => $side, 'reason' => $reason, - 's3_key' => $file, + 's3_key' => $fileKey, 'exception' => get_class($e), 'message' => $e->getMessage(), ], 'candidate.civil-id'); - if ($type === 'resume') { - $this->addError($errorAttribute, Yii::t('app', 'file not available to delete.')); - return false; + if ($bestEffort) { + return true; } - return true; + $this->addError($attribute, Yii::t('app', 'file not available to delete.')); + + return false; } } + /** + * Deletes old Civil ID files that were staged after a verified replacement copy. + * @param string|null $side + * @return bool + */ + public function deletePendingCivilIdFiles($side = null) + { + $pending = $side === null + ? $this->pendingCivilIdDeletionKeys + : [$side => $this->pendingCivilIdDeletionKeys[$side] ?? null]; + + $deleted = true; + foreach ($pending as $pendingSide => $entry) { + if (!$entry || empty($entry['file_key'])) { + continue; + } + + try { + Yii::$app->resourceManager->delete($entry['file_key']); + unset($this->pendingCivilIdDeletionKeys[$pendingSide]); + } catch (\Aws\S3\Exception\S3Exception $e) { + $this->logS3FileFailure('delete', $entry['attribute'], $entry['file_key'], $e); + $deleted = false; + } catch (\Exception $e) { + $this->logS3FileFailure('delete', $entry['attribute'], $entry['file_key'], $e); + $deleted = false; + } + } + + return $deleted; + } + /** * @return bool */ public function updateCivilId($side = 'front') { $idSide = ($side == 'front') ? 'candidate_civil_photo_front' : 'candidate_civil_photo_back'; - $fileName = $this->$idSide; - $sourceBucket = Yii::$app->temporaryBucketResourceManager->bucket; + if (!$fileName) { + $this->addError($idSide, Yii::t('app', 'file not available to save.')); + return false; + } + $sourceBucket = Yii::$app->temporaryBucketResourceManager->bucket; $targetPath = self::normalizeCivilIdPermanentS3Key($fileName); + $oldFilePath = $this->resolveStoredFileKey('civil-id', $side, true); if ($targetPath === '') { $this->addError($idSide, Yii::t('app', 'file not available to save.')); return false; } - // 1) Copy new file from temp bucket to permanent bucket FIRST. - // Only after a successful copy do we touch the old file. try { - Yii::$app->resourceManager->copy($fileName, $targetPath, $sourceBucket); + if (!Yii::$app->resourceManager->fileExists($targetPath)) { + $exception = new \RuntimeException('Copied file could not be verified in the permanent bucket.'); + $this->logS3FileFailure('verify-copy', $idSide, $targetPath, $exception, [ + 'source_bucket' => $sourceBucket, + 'source_filename' => $fileName, + ]); + $this->addError($idSide, Yii::t('app', 'file not available to save.')); + return false; + } + + if ($oldFilePath && $oldFilePath !== $targetPath) { + $this->pendingCivilIdDeletionKeys[$side] = [ + 'attribute' => $idSide, + 'file_key' => $oldFilePath, + ]; + } + + return true; + } catch (\Throwable $e) { + $this->logS3FileFailure('copy', $idSide, $targetPath, $e, [ + 'source_bucket' => $sourceBucket, + 'source_filename' => $fileName, + ]); Yii::error([ - 'action' => 'Candidate::updateCivilId', - 'candidate_id' => $this->candidate_id ?? null, - 'side' => $side, - 'source_key' => $fileName, - 'source_bucket'=> $sourceBucket, - 'target_key' => $targetPath, - 'exception' => get_class($e), - 'message' => $e->getMessage(), + 'action' => 'Candidate::updateCivilId', + 'candidate_id' => $this->candidate_id ?? null, + 'side' => $side, + 'source_key' => $fileName, + 'source_bucket' => $sourceBucket, + 'target_key' => $targetPath, + 'exception' => get_class($e), + 'message' => $e->getMessage(), ], 'candidate.civil-id'); $this->addError($idSide, Yii::t('app', 'file not available to save.')); return false; } - - // 2) Optional post-copy verification. Non-fatal: existing fileExists() - // performs an HTTP HEAD on the public URL, which is fine because - // copy() writes objects with ACL=public-read. - try { - if (!Yii::$app->resourceManager->fileExists($targetPath)) { - Yii::warning([ - 'action' => 'Candidate::updateCivilId', - 'candidate_id' => $this->candidate_id ?? null, - 'side' => $side, - 'target_key' => $targetPath, - 'reason' => 'post-copy verification failed', - ], 'candidate.civil-id'); - } - } catch (\Throwable $e) { - // verification is best-effort; never fail the upload because of it - Yii::warning([ - 'action' => 'Candidate::updateCivilId', - 'candidate_id' => $this->candidate_id ?? null, - 'side' => $side, - 'target_key' => $targetPath, - 'reason' => 'post-copy verification raised', - 'exception' => get_class($e), - 'message' => $e->getMessage(), - ], 'candidate.civil-id'); - } - - // 3) Best-effort delete of the old permanent-bucket file. deleteFile() - // now logs and swallows missing-object failures for civil-id. - $oldNorm = self::normalizeCivilIdPermanentS3Key($this->oldAttributes[$idSide] ?? ''); - $newNorm = self::normalizeCivilIdPermanentS3Key((string)$fileName); - - if ($oldNorm !== '' && $oldNorm !== $newNorm) { - $this->deleteFile('civil-id', $side); - } - - return true; } /** diff --git a/common/tests/unit/models/CandidateS3BehaviorTest.php b/common/tests/unit/models/CandidateS3BehaviorTest.php new file mode 100644 index 00000000..cac0e36a --- /dev/null +++ b/common/tests/unit/models/CandidateS3BehaviorTest.php @@ -0,0 +1,297 @@ +resolveObjectLocation($filenameOrUrl); + } +} + +class CandidateS3BehaviorTest extends \Codeception\Test\Unit +{ + protected $tester; + + public function testDeleteCivilIdUsesPhotosPrefixAndFieldSpecificErrors() + { + $candidate = new CandidateS3BehaviorTestModel(); + $candidate->candidate_id = 1; + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_front' => 'front.jpg', + 'candidate_civil_photo_back' => 'back.jpg', + ]); + + $originalManager = Yii::$app->get('resourceManager'); + + $trackingManager = new class extends \yii\base\Component { + public $deleted = []; + + public function delete($name) + { + $this->deleted[] = $name; + return true; + } + }; + + try { + Yii::$app->set('resourceManager', $trackingManager); + $candidate->clearErrors(); + + $this->assertTrue($candidate->deleteFile('civil-id', 'front')); + $this->assertSame(['photos/front.jpg'], $trackingManager->deleted); + + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_front' => 'photos/existing-front.jpg', + ]); + $this->assertTrue($candidate->deleteFile('civil-id', 'front')); + $this->assertSame([ + 'photos/front.jpg', + 'photos/existing-front.jpg', + ], $trackingManager->deleted); + + $failingManager = new class extends \yii\base\Component { + public function delete($name) + { + throw new \Exception('delete failed'); + } + }; + + Yii::$app->set('resourceManager', $failingManager); + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_back' => 'back.jpg', + ]); + $candidate->clearErrors(); + + $this->assertFalse($candidate->deleteFile('civil-id', 'back')); + $this->assertArrayHasKey('candidate_civil_photo_back', $candidate->getErrors()); + $this->assertArrayNotHasKey('candidate_resume', $candidate->getErrors()); + } finally { + Yii::$app->set('resourceManager', $originalManager); + } + } + + /** + * Verifies old Civil ID files are deleted only after callers finish saving. + */ + public function testUpdateCivilIdCopiesAndDefersOldFileDeletionUntilAfterSave() + { + $candidate = new CandidateS3BehaviorTestModel(); + $candidate->candidate_id = 1; + $candidate->candidate_civil_photo_front = 'new-front.jpg'; + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_front' => 'old-front.jpg', + ]); + + $originalManager = Yii::$app->get('resourceManager'); + $originalTempManager = Yii::$app->get('temporaryBucketResourceManager'); + + $resourceManager = new class extends \yii\base\Component { + public $operations = []; + public $exists = true; + + public function copy($oldFile, $newFile, $sourceBucket = "", $options = []) + { + $this->operations[] = ['copy', $oldFile, $newFile, $sourceBucket]; + return true; + } + + public function fileExists($name) + { + $this->operations[] = ['fileExists', $name]; + return $this->exists; + } + + public function delete($name) + { + $this->operations[] = ['delete', $name]; + return true; + } + }; + + $tempManager = new class extends \yii\base\Component { + public $bucket = 'temp-upload-bucket'; + }; + + try { + Yii::$app->set('resourceManager', $resourceManager); + Yii::$app->set('temporaryBucketResourceManager', $tempManager); + + $candidate->clearErrors(); + $this->assertTrue($candidate->updateCivilId('front')); + $this->assertSame([ + ['copy', 'new-front.jpg', 'photos/new-front.jpg', 'temp-upload-bucket'], + ['fileExists', 'photos/new-front.jpg'], + ], $resourceManager->operations); + + $this->assertTrue($candidate->deletePendingCivilIdFiles('front')); + $this->assertSame([ + ['copy', 'new-front.jpg', 'photos/new-front.jpg', 'temp-upload-bucket'], + ['fileExists', 'photos/new-front.jpg'], + ['delete', 'photos/old-front.jpg'], + ], $resourceManager->operations); + + $candidate->candidate_civil_photo_front = 'verify-miss.jpg'; + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_front' => 'old-front.jpg', + ]); + $candidate->clearErrors(); + + $resourceManager->operations = []; + $resourceManager->exists = false; + + $this->assertFalse($candidate->updateCivilId('front')); + $this->assertSame([ + ['copy', 'verify-miss.jpg', 'photos/verify-miss.jpg', 'temp-upload-bucket'], + ['fileExists', 'photos/verify-miss.jpg'], + ], $resourceManager->operations); + $this->assertArrayHasKey('candidate_civil_photo_front', $candidate->getErrors()); + + $candidate->candidate_civil_photo_front = 'new-front.jpg'; + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_front' => 'photos/persisted-old-front.jpg', + ]); + $candidate->clearErrors(); + + $resourceManager->operations = []; + $resourceManager->exists = true; + + $this->assertTrue($candidate->updateCivilId('front')); + $this->assertTrue($candidate->deletePendingCivilIdFiles('front')); + $this->assertSame([ + ['copy', 'new-front.jpg', 'photos/new-front.jpg', 'temp-upload-bucket'], + ['fileExists', 'photos/new-front.jpg'], + ['delete', 'photos/persisted-old-front.jpg'], + ], $resourceManager->operations); + } finally { + Yii::$app->set('resourceManager', $originalManager); + Yii::$app->set('temporaryBucketResourceManager', $originalTempManager); + } + } + + /** + * Verifies failed deferred Civil ID cleanup remains visible to callers. + */ + public function testPendingCivilIdDeletionReturnsFalseAndCanBeRetried() + { + $candidate = new CandidateS3BehaviorTestModel(); + $candidate->candidate_id = 1; + $candidate->candidate_civil_photo_front = 'new-front.jpg'; + $candidate->setOldAttributes([ + 'candidate_id' => 1, + 'candidate_civil_photo_front' => 'old-front.jpg', + ]); + + $originalManager = Yii::$app->get('resourceManager'); + $originalTempManager = Yii::$app->get('temporaryBucketResourceManager'); + + $resourceManager = new class extends \yii\base\Component { + public $operations = []; + public $failDelete = true; + + public function copy($oldFile, $newFile, $sourceBucket = "", $options = []) + { + $this->operations[] = ['copy', $oldFile, $newFile, $sourceBucket]; + return true; + } + + public function fileExists($name) + { + $this->operations[] = ['fileExists', $name]; + return true; + } + + public function delete($name) + { + $this->operations[] = ['delete', $name]; + if ($this->failDelete) { + throw new \Exception('delete failed'); + } + + return true; + } + }; + + $tempManager = new class extends \yii\base\Component { + public $bucket = 'temp-upload-bucket'; + }; + + try { + Yii::$app->set('resourceManager', $resourceManager); + Yii::$app->set('temporaryBucketResourceManager', $tempManager); + + $this->assertTrue($candidate->updateCivilId('front')); + $this->assertFalse($candidate->deletePendingCivilIdFiles('front')); + + $resourceManager->failDelete = false; + $this->assertTrue($candidate->deletePendingCivilIdFiles('front')); + $this->assertSame([ + ['copy', 'new-front.jpg', 'photos/new-front.jpg', 'temp-upload-bucket'], + ['fileExists', 'photos/new-front.jpg'], + ['delete', 'photos/old-front.jpg'], + ['delete', 'photos/old-front.jpg'], + ], $resourceManager->operations); + } finally { + Yii::$app->set('resourceManager', $originalManager); + Yii::$app->set('temporaryBucketResourceManager', $originalTempManager); + } + } + + /** + * Verifies S3 object resolution handles both URL styles and encoded keys. + */ + public function testS3ResourceManagerResolvesPathStyleAndEncodedUrls() + { + $manager = new CandidateS3BehaviorResourceManager([ + 'key' => 'test-key', + 'secret' => 'test-secret', + 'region' => 'eu-west-1', + 'bucket' => 'default-bucket', + ]); + + $this->assertSame( + ['candidate-bucket', 'photos/front id.jpg'], + $manager->resolveLocation('https://candidate-bucket.s3.eu-west-1.amazonaws.com/photos/front%20id.jpg') + ); + + $this->assertSame( + ['candidate-bucket', 'photos/back id.jpg'], + $manager->resolveLocation('https://s3.eu-west-1.amazonaws.com/candidate-bucket/photos/back%20id.jpg') + ); + } +}