controller = new SqsJobTriggerControllerSpy(); Cache::forget(SqsDrainCommand::LOCK_KEY); } protected function tearDown() { Cache::forget(SqsDrainCommand::LOCK_KEY); parent::tearDown(); } /** * Request data to vary while asserting the command never changes, including * values that would be dangerous if they ever reached a shell. */ private function hostileInputs() { $inputs = [ '', '0', '1', 'abc', '1.5', '12; rm -rf /', '$(whoami)', '`id`', "12\nsqs:drain", '12 && echo x', '--max-time=0', '--once', '|cat', '>out', '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->hostileInputs() as $input) { request()->merge(['injected' => $input, 'id' => $input, 'application_id' => $input]); request()->headers->set('X-Injected', $input); $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 = php_strip_whitespace((new \ReflectionClass(TestingController::class))->getFileName()); $start = strpos($source, 'function launchSqsWorkerProcess'); $body = substr($source, $start, strpos($source, '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 (array_slice($this->hostileInputs(), 0, 25) as $input) { Cache::forget(SqsDrainCommand::LOCK_KEY); request()->merge(['injected' => $input]); // Launched. $response = $this->controller->runSQSJobs(); $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->runSQSJobs(); $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->runSQSJobs(); $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 the 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->runSQSJobs(); $this->assertSame('info', $entries[0]['level']); $this->assertSame($this->controller->lastCommand, $entries[0]['context']['command']); $this->assertSame('info', $entries[1]['level']); $this->assertArrayHasKey('pid', $entries[1]['context']); $this->assertSame(4242, $entries[1]['context']['pid']); // Skip. $this->controller->runSQSJobs(); $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->runSQSJobs(); $failure = end($entries); $this->assertSame('error', $failure['level']); $this->assertSame('boom', $failure['context']['message']); $this->assertArrayHasKey('trace', $failure['context']); // No credential values anywhere. $serialised = json_encode($entries); foreach ([config('queue.connections.sqs.key'), config('queue.connections.sqs.secret')] 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->runSQSJobs(); } $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->runSQSJobs(); $this->assertFalse(Cache::has(SqsDrainCommand::LOCK_KEY), 'The lock must be released on a failed launch.'); $this->controller->runSQSJobs(); $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); } /** * Requirement 1.1: the public trigger method exists and takes no parameters. */ public function testMethodSignature() { $method = new ReflectionMethod(TestingController::class, 'runSQSJobs'); $this->assertTrue($method->isPublic()); $this->assertSame(0, $method->getNumberOfParameters()); } /** * Requirement 6.4 / middleware wiring: the route uri must appear in * JwtEMICheckoutMiddleware::$jwt_path. When it does not, that middleware falls * through its conditional and returns null without calling the next handler, * which surfaces as a type error inside ThrottleRequests rather than as * anything pointing at this endpoint. */ public function testRouteUriIsWhitelistedInTheJwtMiddleware() { $route = null; foreach (app('router')->getRoutes() as $candidate) { if (strpos($candidate->uri(), 'automation/SQSJob') !== false) { $route = $candidate; } } $this->assertNotNull($route, 'The automation/SQSJob route must be registered.'); $this->assertSame( 'App\Http\Controllers\TestingController@runSQSJobs', $route->getActionName() ); $middleware = new \ReflectionClass(\App\Http\Middleware\JwtEMICheckoutMiddleware::class); $property = $middleware->getProperty('jwt_path'); $property->setAccessible(true); $whitelist = $property->getValue($middleware->newInstanceWithoutConstructor()); $this->assertContains( $route->uri(), $whitelist, 'The route uri must be whitelisted or the jwt middleware returns null.' ); $gathered = $route->gatherMiddleware(); $this->assertContains('throttle:60,1', $gathered); $this->assertContains('jwt.emi_checkout', $gathered); } } /** * 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; } }