2
0

WriteAsAiModelTest.php 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. <?php
  2. use App\Models\AiModel;
  3. use App\Models\Sentence;
  4. use Firebase\JWT\JWT;
  5. use Illuminate\Foundation\Testing\RefreshDatabase;
  6. uses(RefreshDatabase::class);
  7. /**
  8. * 端到端串起 wikipali-write Skill 的完整写入链路:
  9. * 建模型 → 取 model token → 签 channel access token → 以模型身份写句子。
  10. *
  11. * 这条链路的意义全在最后一个断言上:句子的 editor_uid 必须是模型 uid,
  12. * 而不是发起操作的人类用户,否则 AI 署名与审计就是假的。
  13. */
  14. it('writes a sentence attributed to the ai model, not the human operator', function () {
  15. $human = makeStudio('tester');
  16. $channel = makeChannel($human);
  17. $model = AiModel::factory()->ownedBy($human)->create(['name' => 'claude-opus-5']);
  18. // 1. 人类身份签出 channel 的 access token
  19. $accessToken = $this->postJson('/api/v2/access-token', [
  20. 'payload' => [[
  21. 'res_type' => 'channel',
  22. 'res_id' => $channel,
  23. 'power' => 'edit',
  24. // book 必须是整数:UserCanEdit 用 !== 严格比较,"0" 会恒不等
  25. 'book' => 0,
  26. ]],
  27. ], authHeader($human))
  28. ->assertOk()
  29. ->assertJsonPath('data.count', 1)
  30. ->json('data.rows.0.token');
  31. // 2. 人类身份取模型的身份 token
  32. $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  33. ->assertOk()
  34. ->json('data.token');
  35. // 3. 以「模型身份」写句子:Authorization 是 modelToken,句内带 accessToken
  36. $this->postJson('/api/v2/sentence', [
  37. 'sentences' => [[
  38. 'book_id' => 1,
  39. 'paragraph' => 10,
  40. 'word_start' => 0,
  41. 'word_end' => 12,
  42. 'channel_uid' => $channel,
  43. 'content' => '这是 AI 写入的译文',
  44. 'content_type' => 'markdown',
  45. 'access_token' => $accessToken,
  46. ]],
  47. ], ['Authorization' => 'Bearer '.$modelToken])
  48. ->assertOk()
  49. ->assertJsonPath('data.count', 1);
  50. $saved = Sentence::where('channel_uid', $channel)->first();
  51. expect($saved)->not->toBeNull();
  52. expect($saved->content)->toBe('这是 AI 写入的译文');
  53. // 核心断言:署名归模型
  54. expect($saved->editor_uid)->toBe($model->uid);
  55. expect($saved->editor_uid)->not->toBe($human);
  56. });
  57. it('refuses the write when the access token is for a different channel', function () {
  58. $human = makeStudio('tester');
  59. $channel = makeChannel($human, 'mine');
  60. $otherChannel = makeChannel(makeStudio('someone-else'), 'not mine');
  61. $model = AiModel::factory()->ownedBy($human)->create();
  62. $accessToken = $this->postJson('/api/v2/access-token', [
  63. 'payload' => [[
  64. 'res_type' => 'channel',
  65. 'res_id' => $channel,
  66. 'power' => 'edit',
  67. 'book' => 0,
  68. ]],
  69. ], authHeader($human))->json('data.rows.0.token');
  70. $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  71. ->json('data.token');
  72. // 拿 A channel 的 token 去写 B channel:逐句静默跳过,count 为 0
  73. $this->postJson('/api/v2/sentence', [
  74. 'sentences' => [[
  75. 'book_id' => 1,
  76. 'paragraph' => 10,
  77. 'word_start' => 0,
  78. 'word_end' => 12,
  79. 'channel_uid' => $otherChannel,
  80. 'content' => 'should not land',
  81. 'access_token' => $accessToken,
  82. ]],
  83. ], ['Authorization' => 'Bearer '.$modelToken])
  84. ->assertOk()
  85. ->assertJsonPath('data.count', 0);
  86. expect(Sentence::count())->toBe(0);
  87. });
  88. it('rejects an expired access token with 403 instead of 500', function () {
  89. $human = makeStudio('tester');
  90. $channel = makeChannel($human);
  91. $model = AiModel::factory()->ownedBy($human)->create();
  92. $accessToken = $this->postJson('/api/v2/access-token', [
  93. 'payload' => [[
  94. 'res_type' => 'channel',
  95. 'res_id' => $channel,
  96. 'power' => 'edit',
  97. 'book' => 0,
  98. ]],
  99. ], authHeader($human))->json('data.rows.0.token');
  100. $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
  101. ->json('data.token');
  102. // 把时钟拨到 7 天有效期之后。
  103. // 注意不能用 $this->travel():那只动 Carbon,而 JWT::decode 读的是 PHP 的 time(),
  104. // 得改 JWT::$timestamp 这个专供测试的静态覆盖点。
  105. // model token(365 天)在 +8 天时仍然有效,所以这里过期的只有 access token。
  106. JWT::$timestamp = time() + 8 * 24 * 60 * 60;
  107. try {
  108. $this->postJson('/api/v2/sentence', [
  109. 'sentences' => [[
  110. 'book_id' => 1,
  111. 'paragraph' => 10,
  112. 'word_start' => 0,
  113. 'word_end' => 12,
  114. 'channel_uid' => $channel,
  115. 'content' => 'expired',
  116. 'access_token' => $accessToken,
  117. ]],
  118. ], ['Authorization' => 'Bearer '.$modelToken])
  119. ->assertOk()
  120. ->assertJsonPath('data.count', 0);
  121. } finally {
  122. JWT::$timestamp = null;
  123. }
  124. expect(Sentence::count())->toBe(0);
  125. });