'sqlite', 'database.connections.sqlite.database' => ':memory:']); DB::purge('sqlite'); (require database_path('migrations/2026_10_23_100000_create_content_security_checks.php'))->up(); } protected function tearDown(): void { DB::disconnect('sqlite'); parent::tearDown(); } private function pending() { return DB::table('content_security_checks')->insertGetId(['user_id' => 1, 'upload_id' => 7, 'trace_id' => 'trace-1', 'url' => 'https://example.test/avatar.jpg', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); } public function test_pending_reject_error_and_foreign_images_cannot_be_saved() { $this->pending(); $service = new AvatarCheck; foreach (['pending', 'reject', 'error', 'pass'] as $status) { DB::table('content_security_checks')->update(['status' => $status]); foreach ([1, 2] as $user) { try { $service->assertApproved($user, 'https://example.test/avatar.jpg'); $allowed = true; } catch (\InvalidArgumentException $e) { $allowed = false; } $this->assertSame($status === 'pass' && $user === 1, $allowed); } } $this->expectException(\InvalidArgumentException::class); $service->assertApproved(1, 'https://example.test/arbitrary.jpg'); } public function test_only_successful_pass_callback_approves_and_duplicates_are_ignored() { $id = $this->pending(); $service = new AvatarCheck; $this->assertNull($service->result(1, $id)['url']); $service->receive(['Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']]); $this->assertSame('pass', $service->result(1, $id)['status']); $service->receive(['Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'risky']]); $this->assertSame('pass', $service->result(1, $id)['status']); $this->expectException(\InvalidArgumentException::class); $service->result(2, $id); } public function test_review_and_missing_results_are_not_pass() { foreach ([['errcode'=>0,'result'=>['suggest'=>'review']], ['errcode'=>0], ['errcode'=>-1,'result'=>['suggest'=>'pass']]] as $data) { DB::table('content_security_checks')->delete(); $id=$this->pending(); (new AvatarCheck)->receive(array_merge(['Event'=>'wxa_media_check','trace_id'=>'trace-1'], $data)); $this->assertNotSame('pass', (new AvatarCheck)->result(1, $id)['status']); } } public function test_callback_rejects_unsigned_requests() { config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]); $this->postJson('/api/wechat/content-security/callback', ['Event'=>'wxa_media_check'])->assertStatus(400); } public function test_signed_callback_updates_check_and_returns_success() { $id=$this->pending(); config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]); $timestamp=(string)time(); $nonce='test-nonce'; $parts=['test-token',$timestamp,$nonce]; sort($parts,SORT_STRING); $url='/api/wechat/content-security/callback?timestamp='.$timestamp.'&nonce='.$nonce.'&signature='.sha1(implode('', $parts)); $this->postJson($url, ['MsgType'=>'event','Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']])->assertOk(); $this->assertSame('pass', (new AvatarCheck)->result(1,$id)['status']); } public function test_encrypted_callback_is_decrypted_and_checked() { $id=$this->pending(); $token='test-token'; $key=str_repeat('a',43); $appid='wx-test'; config(['content-security.token'=>$token,'content-security.aes_key'=>$key,'app.applet_appid'=>$appid]); $timestamp=time(); $nonce='encrypted-nonce'; $parts=[$token,(string)$timestamp,$nonce]; sort($parts,SORT_STRING); $message=json_encode(['MsgType'=>'event','Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']]); $encrypted=(new \EasyWeChat\MiniProgram\Encryptor($appid,$token,$key))->encrypt($message,$nonce,$timestamp); $envelope=\EasyWeChat\Kernel\Support\XML::parse($encrypted); $url='/api/wechat/content-security/callback?encrypt_type=aes×tamp='.$timestamp.'&nonce='.$nonce.'&signature='.sha1(implode('',$parts)).'&msg_signature='.$envelope['MsgSignature']; $this->call('POST',$url,[],[],[],['CONTENT_TYPE'=>'text/xml'],$encrypted)->assertOk(); $this->assertSame('pass',(new AvatarCheck)->result(1,$id)['status']); } public function test_update_endpoint_refuses_unapproved_avatar_without_changing_user() { $this->withoutMiddleware(); Schema::create('users',function(Blueprint $t){$t->id();$t->string('headimgurl')->nullable();$t->timestamps();$t->softDeletes();}); DB::table('users')->insert(['id'=>1,'headimgurl'=>'old']); \Illuminate\Support\Facades\Auth::guard('mobile')->setUser(\App\Models\User::find(1)); $this->postJson('/api/mobile/user/update-user',['headimgurl'=>'https://example.test/unreviewed.jpg'])->assertOk()->assertJsonStructure(['errcode','errmsg']); $this->assertSame('old',DB::table('users')->where('id',1)->value('headimgurl')); \Illuminate\Support\Facades\Auth::guard('mobile')->forgetUser(); } public function test_callback_url_verification_echoes_only_with_valid_signature() { config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]); $timestamp=(string)time(); $nonce='verify'; $parts=['test-token',$timestamp,$nonce]; sort($parts,SORT_STRING); $url='/api/wechat/content-security/callback?timestamp='.$timestamp.'&nonce='.$nonce.'&echostr=hello&signature='; $this->get($url.'invalid')->assertStatus(400); $this->get($url.sha1(implode('',$parts)))->assertOk()->assertSeeText('hello'); } }