controller = new SqsJobTriggerControllerSpy(); Cache::forget(SqsDrainCommand::LOCK_KEY); } protected function tearDown() { Cache::forget(SqsDrainCommand::LOCK_KEY); parent::tearDown(); } /** * A spread of placeholder ids, including hostile ones. */ private function placeholderIds() { $ids = [ 0, 1, -1, 123, PHP_INT_MAX, '0', '1', '00123', '-7', '', null, false, true, 'abc', '1.5', 1.5, '1e3', ' 12 ', '12; rm -rf /', '$(whoami)', '`id`', "12\nsqs:drain", '12 && echo x', '--max-time=0', '|cat', '>out', 'setAccessible(true); foreach ($this->placeholderIds() as $id) { $result = $normalise->invoke($this->controller, $id); $this->assertInternalType('integer', $result, 'Normalisation must always return an integer.'); $isIntegerValue = is_int($id) || (is_string($id) && preg_match('/^-?\d+$/', $id)); if ($isIntegerValue) { $this->assertSame((int) $id, $result); } else { $this->assertSame(0, $result, 'Non-integer input must normalise to 0.'); } } } /** * Feature: async-sqs-job-trigger, Property 1: Worker_Command is independent of * the request. For any placeholder id, query parameters, headers or body, * including shell metacharacters, the command array is identical. * * Feature: async-sqs-job-trigger, Property 3: Worker_Command composition is * invariant - the resolved binary, base_path('artisan') and sqs:drain in that * order, with none of queue:work, --max-time, --max-jobs, --once, * --stop-when-empty. */ public function testWorkerCommandIsRequestIndependentAndWellFormed() { $build = new ReflectionMethod(TestingController::class, 'buildSqsWorkerCommand'); $build->setAccessible(true); // No parameters at all is the structural guarantee of request-independence. $this->assertSame(0, $build->getNumberOfParameters()); $baseline = $build->invoke($this->controller); foreach ($this->placeholderIds() as $id) { request()->merge(['injected' => $id]); request()->headers->set('X-Injected', is_scalar($id) ? (string) $id : 'non-scalar'); $this->assertSame($baseline, $build->invoke($this->controller)); } $this->assertCount(3, $baseline); $this->assertSame(base_path('artisan'), $baseline[1]); $this->assertSame('sqs:drain', $baseline[2]); foreach (['queue:work', '--max-time', '--max-jobs', '--once', '--stop-when-empty'] as $forbidden) { foreach ($baseline as $element) { $this->assertNotContains($forbidden, (string) $element); } } } /** * Feature: async-sqs-job-trigger, Property 4: PHP binary resolution always * yields a usable single argument - non-empty, no whitespace-separated extra * arguments. */ public function testPhpBinaryResolutionYieldsSingleArgument() { $resolve = new ReflectionMethod(TestingController::class, 'resolveSqsPhpBinary'); $resolve->setAccessible(true); for ($i = 0; $i < 100; $i++) { $binary = $resolve->invoke($this->controller); $this->assertInternalType('string', $binary); $this->assertNotSame('', $binary); $this->assertSame($binary, trim($binary)); $this->assertNotRegExp('/\s/', $binary, 'The binary must be a single argv entry.'); } } /** * Feature: async-sqs-job-trigger, Property 5: No Worker_Command element carries * shell syntax, and the command is passed to Process as an array. */ public function testNoCommandElementCarriesShellSyntax() { $build = new ReflectionMethod(TestingController::class, 'buildSqsWorkerCommand'); $build->setAccessible(true); $command = $build->invoke($this->controller); foreach ($command as $element) { $this->assertInternalType('string', $element); $this->assertNotSame('', $element); foreach ([';', '|', '&', '$', '`', '>', '<', "\n"] as $metacharacter) { $this->assertNotContains( $metacharacter, $element, 'Command element must not carry shell syntax: '.$element ); } } // Passed as an array, never as a joined string. $parameter = (new \ReflectionMethod(TestingController::class, 'launchSqsWorkerProcess'))->getParameters()[0]; $this->assertTrue($parameter->isArray()); } /** * Feature: async-sqs-job-trigger, Property 6: The process is started without * blocking - started via start(), with no blocking completion call. */ public function testProcessIsStartedWithoutBlocking() { $source = file_get_contents((new \ReflectionClass(TestingController::class))->getFileName()); $start = strpos($source, 'protected function launchSqsWorkerProcess'); $body = substr($source, $start, strpos($source, 'protected function detachSqsWorkerProcess') - $start); $this->assertContains('->start()', $body); $this->assertNotContains('->run()', $body); $this->assertNotContains('->mustRun()', $body); $this->assertNotContains('->wait()', $body); } /** * Feature: async-sqs-job-trigger, Property 17: Response shape and status are * fixed - exactly status/message on launch or skip, exactly status/error on a * caught exception, always HTTP 200. */ public function testResponseShapeIsFixedAcrossOutcomes() { foreach ($this->placeholderIds() as $id) { Cache::forget(SqsDrainCommand::LOCK_KEY); // Launched. $response = $this->controller->runSQSJobById($id); $this->assertSame(200, $response->getStatusCode()); $body = json_decode($response->getContent(), true); $this->assertSame(['status', 'message'], array_keys($body)); $this->assertSame('OK', $body['status']); $this->assertSame('success', $body['message']); // Skipped: the lock is still held from the launch above. $response = $this->controller->runSQSJobById($id); $this->assertSame(200, $response->getStatusCode()); $body = json_decode($response->getContent(), true); $this->assertSame(['status', 'message'], array_keys($body)); $this->assertSame('OK', $body['status']); $this->assertContains('already running', $body['message']); } // Failure. Cache::forget(SqsDrainCommand::LOCK_KEY); $failing = new SqsJobTriggerControllerSpy(); $failing->throwOnLaunch = new \RuntimeException('launch exploded'); $response = $failing->runSQSJobById(7); $this->assertSame(200, $response->getStatusCode(), 'A caught exception must not become a 500.'); $body = json_decode($response->getContent(), true); $this->assertSame(['status', 'error'], array_keys($body)); $this->assertSame('FAIL', $body['status']); $this->assertSame('launch exploded', $body['error']); } /** * Feature: async-sqs-job-trigger, Property 18: Every trigger decision point is * logged - info on request with id and command, info with pid on launch, info on * skip, error with message and trace on failure. * * Feature: async-sqs-job-trigger, Property 20: Credentials never reach the logs. */ public function testEveryTriggerDecisionPointIsLogged() { $entries = []; $collect = function ($level) use (&$entries) { return function ($message, $context = []) use ($level, &$entries) { $entries[] = ['level' => $level, 'message' => $message, 'context' => $context]; }; }; \Illuminate\Support\Facades\Log::shouldReceive('info')->andReturnUsing($collect('info')); \Illuminate\Support\Facades\Log::shouldReceive('error')->andReturnUsing($collect('error')); \Illuminate\Support\Facades\Log::shouldReceive('debug')->andReturnUsing($collect('debug')); // Launch. $this->controller->runSQSJobById(42); $this->assertSame('info', $entries[0]['level']); $this->assertSame(42, $entries[0]['context']['job_id']); $this->assertSame($this->controller->lastCommand, $entries[0]['context']['command']); $this->assertSame('info', $entries[1]['level']); $this->assertArrayHasKey('pid', $entries[1]['context']); // Skip. $this->controller->runSQSJobById(42); $skip = end($entries); $this->assertSame('info', $skip['level']); $this->assertContains('skipped', $skip['message']); // Failure. Cache::forget(SqsDrainCommand::LOCK_KEY); $failing = new SqsJobTriggerControllerSpy(); $failing->throwOnLaunch = new \RuntimeException('boom'); $failing->runSQSJobById(9); $failure = end($entries); $this->assertSame('error', $failure['level']); $this->assertSame('boom', $failure['context']['message']); $this->assertArrayHasKey('trace', $failure['context']); // No credential values anywhere. $credentials = array_filter([config('queue.connections.sqs.key'), config('queue.connections.sqs.secret')]); $serialised = json_encode($entries); foreach ($credentials as $credential) { if (is_string($credential) && strlen($credential) > 4) { $this->assertNotContains($credential, $serialised); } } } /** * Feature: async-sqs-job-trigger, Property 7: Exactly one drain launches per * lock window; later invocations report that a worker is already running. */ public function testExactlyOneDrainLaunchesPerLockWindow() { for ($attempt = 0; $attempt < 10; $attempt++) { $this->controller->runSQSJobById($attempt); } $this->assertSame(1, $this->controller->launchCount, 'Only one drain may launch per lock window.'); } /** * Feature: async-sqs-job-trigger, Property 9: A failed launch leaves no held * lock, so a later invocation can launch. */ public function testFailedLaunchLeavesNoHeldLock() { $failing = new SqsJobTriggerControllerSpy(); $failing->throwOnLaunch = new \RuntimeException('nope'); $failing->runSQSJobById(1); $this->assertFalse(Cache::has(SqsDrainCommand::LOCK_KEY), 'The lock must be released on a failed launch.'); $this->controller->runSQSJobById(1); $this->assertSame(1, $this->controller->launchCount); } /** * Feature: async-sqs-job-trigger, Property 8 (partial): the lock TTL exceeds the * heartbeat interval by a workable margin, so a live drain holds it across * refreshes. */ public function testLockTtlExceedsHeartbeatInterval() { $ttlSeconds = SqsDrainCommand::LOCK_MINUTES * 60; $this->assertGreaterThanOrEqual(90, $ttlSeconds); $this->assertGreaterThan(SqsDrainCommand::HEARTBEAT_SECONDS, $ttlSeconds); $this->assertGreaterThanOrEqual(90, $ttlSeconds - SqsDrainCommand::HEARTBEAT_SECONDS); } /** * Requirement 1.1: the public method exists with one parameter. */ public function testMethodSignature() { $method = new ReflectionMethod(TestingController::class, 'runSQSJobById'); $this->assertTrue($method->isPublic()); $this->assertSame(1, $method->getNumberOfParameters()); } } /** * Records launches instead of spawning processes. */ class SqsJobTriggerControllerSpy extends TestingController { public $launchCount = 0; public $lastCommand = null; public $throwOnLaunch = null; protected function launchSqsWorkerProcess(array $command) { $this->lastCommand = $command; if ($this->throwOnLaunch) { throw $this->throwOnLaunch; } $this->launchCount++; return 4242; } }