You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wx.sstbc.com/tests/Feature/AvatarContentSecurityTest.php

93 lines
6.2 KiB

<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Services\ContentSecurity\AvatarCheck;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
class AvatarContentSecurityTest extends TestCase
{
protected function setUp(): void {
parent::setUp();
config(['database.default' => '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&timestamp='.$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');
}
}