2
0

AgentGrantV3Test.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. <?php
  2. use App\Models\AiModel;
  3. use App\Models\Discussion;
  4. use App\Services\AuthService;
  5. use Illuminate\Foundation\Testing\RefreshDatabase;
  6. uses(RefreshDatabase::class);
  7. /*
  8. * agent 协作:人类签发授权(POST /v3/agent-grants),模型凭模型 token + X-Agent-Grant 代写。
  9. * 代写的记录 editor = 授权人、co_editor = 模型,权限按授权人判、范围限授权的 channel / book。
  10. */
  11. /** 模型身份的请求头;$grant 给了就带上授权 */
  12. function modelHeader(AiModel $model, ?string $grant = null): array
  13. {
  14. $headers = ['Authorization' => 'Bearer '.AuthService::getUserToken($model->uid)];
  15. if ($grant !== null) {
  16. $headers['X-Agent-Grant'] = $grant;
  17. }
  18. return $headers;
  19. }
  20. /** 人类为自己的模型在 channel 上签一张授权,返回 token */
  21. function grantFor(string $human, AiModel $model, string $channel, int $book = 0): string
  22. {
  23. return test()->postJson('/api/v3/agent-grants', [
  24. 'ai_model' => $model->uid, 'target_type' => 'channel', 'target_id' => $channel, 'book' => $book,
  25. ], authHeader($human))->assertCreated()->json('data.token');
  26. }
  27. /* ------------------------------------------------------------------ 签发 */
  28. it('issues a grant bound to the model, the channel and the grantor', function () {
  29. $human = makeStudio('owner');
  30. $channel = makeChannel($human);
  31. $model = AiModel::factory()->ownedBy($human)->create();
  32. $this->postJson('/api/v3/agent-grants', [
  33. 'ai_model' => $model->uid, 'target_type' => 'channel', 'target_id' => $channel, 'book' => 3,
  34. ], authHeader($human))
  35. ->assertCreated()
  36. ->assertJsonPath('data.grantor', $human)
  37. ->assertJsonPath('data.ai_model', $model->uid)
  38. ->assertJsonPath('data.target_id', $channel)
  39. ->assertJsonPath('data.book', 3)
  40. ->assertJsonStructure(['data' => ['token', 'expires_at']]);
  41. });
  42. it('refuses to issue a grant without the right to do so', function () {
  43. $human = makeStudio('owner');
  44. $channel = makeChannel($human);
  45. $model = AiModel::factory()->ownedBy($human)->create();
  46. $body = ['ai_model' => $model->uid, 'target_type' => 'channel', 'target_id' => $channel];
  47. $this->postJson('/api/v3/agent-grants', $body)->assertUnauthorized();
  48. $this->postJson('/api/v3/agent-grants', $body, modelHeader($model))->assertForbidden(); // 模型不能给自己授权
  49. $this->postJson('/api/v3/agent-grants', $body, authHeader(makeStudio('stranger')))->assertForbidden(); // 模型不是他的
  50. $other = makeChannel(makeStudio('other'));
  51. $this->postJson('/api/v3/agent-grants', [...$body, 'target_id' => $other], authHeader($human))->assertForbidden(); // channel 编不了
  52. $this->postJson('/api/v3/agent-grants', [...$body, 'ai_model' => (string) Str::uuid()], authHeader($human))
  53. ->assertStatus(422)->assertJsonValidationErrors('ai_model');
  54. $this->postJson('/api/v3/agent-grants', [...$body, 'target_type' => 'article'], authHeader($human))
  55. ->assertStatus(422)->assertJsonValidationErrors('target_type');
  56. });
  57. /* ------------------------------------------------------------------ 代写 */
  58. it('records the grantor as editor and the model as co-editor', function () {
  59. $human = makeStudio('owner');
  60. $channel = makeChannel($human);
  61. $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
  62. $model = AiModel::factory()->ownedBy($human)->create();
  63. $grant = grantFor($human, $model, $channel);
  64. // 批注:只有句子的管理者能建——按授权人判,所以模型能写进来
  65. $note = $this->postJson('/api/v3/discussions', [
  66. 'target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'commentary', 'content' => '{{1-2-3-4}}',
  67. ], modelHeader($model, $grant))
  68. ->assertCreated()
  69. ->assertJsonPath('data.editor.id', $human)
  70. ->assertJsonPath('data.co_editor.id', $model->uid)
  71. ->json('data');
  72. $this->postJson("/api/v3/discussions/{$note['id']}/replies", ['content' => '补充', 'notify' => false], modelHeader($model, $grant))
  73. ->assertCreated()
  74. ->assertJsonPath('data.co_editor.id', $model->uid);
  75. $this->patchJson("/api/v3/discussions/{$note['id']}", ['pos_end' => 2], modelHeader($model, $grant))->assertOk();
  76. expect(Discussion::find($note['id']))
  77. ->editor_uid->toBe($human)
  78. ->co_editor_uid->toBe($model->uid);
  79. // 人类自己写的没有协作者
  80. $this->getJson("/api/v3/discussions/{$note['id']}")->assertJsonPath('data.co_editor.id', $model->uid);
  81. $this->postJson('/api/v3/discussions', [
  82. 'target_type' => 'sentence', 'target_id' => $sentence, 'title' => 't', 'notify' => false,
  83. ], authHeader($human))->assertCreated()->assertJsonPath('data.co_editor', null);
  84. });
  85. it('rejects agent writes that are not covered by a valid grant', function () {
  86. $human = makeStudio('owner');
  87. $channel = makeChannel($human);
  88. $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
  89. $model = AiModel::factory()->ownedBy($human)->create();
  90. $body = ['target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'note', 'content' => 'x'];
  91. $this->postJson('/api/v3/discussions', $body, modelHeader($model))->assertForbidden(); // 没授权
  92. $this->postJson('/api/v3/discussions', $body, modelHeader($model, 'garbage'))->assertForbidden(); // 伪造
  93. $otherChannel = makeChannel($human, 'other');
  94. $this->postJson('/api/v3/discussions', $body, modelHeader($model, grantFor($human, $model, $otherChannel)))
  95. ->assertForbidden(); // 别的 channel
  96. $this->postJson('/api/v3/discussions', $body, modelHeader($model, grantFor($human, $model, $channel, 2)))
  97. ->assertForbidden(); // 别的 book
  98. $another = AiModel::factory()->ownedBy($human)->create();
  99. $this->postJson('/api/v3/discussions', $body, modelHeader($another, grantFor($human, $model, $channel)))
  100. ->assertForbidden(); // 授权给的不是它
  101. // 人类带授权:授权只给模型用
  102. $this->postJson('/api/v3/discussions', $body, [...authHeader($human), 'X-Agent-Grant' => grantFor($human, $model, $channel)])
  103. ->assertStatus(422);
  104. });
  105. it('does not accept a v2 access token as a grant', function () {
  106. $human = makeStudio('owner');
  107. $channel = makeChannel($human);
  108. $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
  109. $model = AiModel::factory()->ownedBy($human)->create();
  110. // v2 的载荷由客户端决定,伪造 grantor / act 也签得出来
  111. $v2 = $this->postJson('/api/v2/access-token', ['payload' => [[
  112. 'res_type' => 'channel', 'res_id' => $channel, 'power' => 'edit', 'book' => 0,
  113. 'typ' => 'agent-grant', 'grantor' => $human, 'act' => $model->uid, 'ver' => 0,
  114. ]]], authHeader($human))->json('data.rows.0.token');
  115. $this->postJson('/api/v3/discussions', [
  116. 'target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'note', 'content' => 'x',
  117. ], modelHeader($model, $v2))->assertForbidden();
  118. });
  119. it('voids grants when the model token is revoked', function () {
  120. $human = makeStudio('owner');
  121. $channel = makeChannel($human);
  122. $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
  123. $model = AiModel::factory()->ownedBy($human)->create();
  124. $grant = grantFor($human, $model, $channel);
  125. $model->increment('token_version'); // 撤销:v2 DELETE /v2/ai-model-token 做的就是这个
  126. $this->postJson('/api/v3/discussions', [
  127. 'target_type' => 'sentence', 'target_id' => $sentence, 'type' => 'note', 'content' => 'x',
  128. ], modelHeader($model->fresh(), $grant))->assertForbidden();
  129. });
  130. it('lets the grantor, not the model, own what the model wrote', function () {
  131. $human = makeStudio('owner');
  132. $channel = makeChannel($human);
  133. $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
  134. $model = AiModel::factory()->ownedBy($human)->create();
  135. $id = $this->postJson('/api/v3/discussions', [
  136. 'target_type' => 'sentence', 'target_id' => $sentence, 'title' => 'by agent', 'notify' => false,
  137. ], modelHeader($model, grantFor($human, $model, $channel)))->json('data.id');
  138. $this->getJson("/api/v3/discussions/{$id}", authHeader($human))
  139. ->assertJsonPath('data.abilities.update', true)
  140. ->assertJsonPath('data.abilities.delete', true);
  141. $this->deleteJson("/api/v3/discussions/{$id}", [], authHeader($human))->assertOk();
  142. });
  143. it('clears the co-editor when a person edits the content, but not on a status change', function () {
  144. $human = makeStudio('owner');
  145. $channel = makeChannel($human);
  146. $sentence = makeSentence($channel, 1, 1, 1, 'text')->uid;
  147. $model = AiModel::factory()->ownedBy($human)->create();
  148. $grant = grantFor($human, $model, $channel);
  149. $id = $this->postJson('/api/v3/discussions', [
  150. 'target_type' => 'sentence', 'target_id' => $sentence, 'title' => 'by agent', 'notify' => false,
  151. ], modelHeader($model, $grant))->json('data.id');
  152. $this->patchJson("/api/v3/discussions/{$id}", ['status' => 'close'], authHeader($human))
  153. ->assertJsonPath('data.co_editor.id', $model->uid); // 管理动作,不动署名
  154. $this->patchJson("/api/v3/discussions/{$id}", ['content' => '人改过'], authHeader($human))
  155. ->assertJsonPath('data.co_editor', null); // 人亲自改了内容
  156. $this->patchJson("/api/v3/discussions/{$id}", ['content' => 'AI 又改'], modelHeader($model, $grant))
  157. ->assertJsonPath('data.co_editor.id', $model->uid); // AI 代改,换回模型
  158. });