2
0

06_auth.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <?php
  2. use \WikipaliApi\Api\EmailCertificationsApi;
  3. use \WikipaliApi\Api\InvitesApi;
  4. use \WikipaliApi\Api\MeApi;
  5. use \WikipaliApi\Api\PasswordResetsApi;
  6. use \WikipaliApi\Api\SessionsApi;
  7. use \WikipaliApi\Api\UsersApi;
  8. use \WikipaliApi\Model\PatchApiV3PasswordResetsTokenRequest;
  9. use \WikipaliApi\Model\PostApiV3InvitesRequest;
  10. use \WikipaliApi\Model\PostApiV3PasswordResetsRequest;
  11. use \WikipaliApi\Model\PostApiV3SessionsRequest;
  12. use \WikipaliApi\Model\PostApiV3UsersRequest;
  13. /*
  14. * 账号:登录、我是谁、找回密码、注册。除 /v3/me 外全部匿名端点。
  15. *
  16. * ⚠️ 这些用例会在所有服务器(含 prod)上真实执行,所以**只走不产生副作用的路径**:
  17. * - 不给任何真实邮箱发信:找回密码只用未注册的 `.invalid` 邮箱(服务端什么都不发);
  18. * 注册验证码只测参数校验失败(发码的成功路径会真的发邮件,不测)。
  19. * - 不建账号:POST /v3/users 只用不存在的 invite,在建号之前就被拒。
  20. * 完整的成功路径(收码 → 换 invite → 建号 → 重置密码)由 api-v13 的 Pest 测试覆盖
  21. * (tests/Feature/SignUpV3Test.php、PasswordResetV3Test.php),那里 Mail 是 fake 的。
  22. *
  23. * 发信类端点有限流(同 IP 每分钟 10 次、同邮箱每小时 5 次),本文件每次跑只打几次,
  24. * 但短时间内反复跑全量可能撞到 429。
  25. */
  26. /** 每次运行不同的未注册邮箱:`.invalid` 是 RFC 2606 保留域,不会有人收到信 */
  27. function auth_unregistered_email(): string
  28. {
  29. static $email = null;
  30. return $email ??= 'api-test-' . bin2hex(random_bytes(6)) . '@example.invalid';
  31. }
  32. /**
  33. * 格式不合法的邮箱,每次运行不同。
  34. *
  35. * 发信类端点还按邮箱每小时限 5 次,而且限流先于参数校验执行——写死一个
  36. * `not-an-email` 的话,一小时内跑到第 6 遍就从 422 变成 429。
  37. */
  38. function auth_malformed_email(): string
  39. {
  40. return 'not-an-email-' . bin2hex(random_bytes(4));
  41. }
  42. /** 格式合法但不可能存在的 reset token(64 位字母数字) */
  43. const AUTH_UNKNOWN_RESET_TOKEN = 'apitest000000000000000000000000000000000000000000000000000000000';
  44. /* ---- /v3/password-resets ---- */
  45. test('POST /v3/password-resets(未注册邮箱)→ 204,不泄露账号是否存在', function () {
  46. $body = new PostApiV3PasswordResetsRequest();
  47. $body->setEmail(auth_unregistered_email());
  48. $body->setLang('zh-Hans');
  49. $res = request(fn () => api(PasswordResetsApi::class)->postApiV3PasswordResetsWithHttpInfo($body));
  50. assert_status(204, $res, 'password-resets 未注册邮箱');
  51. });
  52. test('POST /v3/password-resets(邮箱格式错)→ 422 errors.email', function () {
  53. $res = raw('POST', '/v3/password-resets', null, ['email' => auth_malformed_email()]);
  54. assert_field_error('email', $res, 'password-resets 邮箱格式错');
  55. });
  56. test('POST /v3/password-resets(缺 email)→ 422 errors.email', function () {
  57. $res = raw('POST', '/v3/password-resets', null, []);
  58. assert_field_error('email', $res, 'password-resets 缺 email');
  59. });
  60. test('GET /v3/password-resets/{token}(不存在)→ 404', function () {
  61. $res = request(fn () => api(PasswordResetsApi::class)->getApiV3PasswordResetsTokenWithHttpInfo(AUTH_UNKNOWN_RESET_TOKEN));
  62. assert_status(404, $res, 'password-resets show 不存在');
  63. assert_problem($res, 'password-resets show 不存在');
  64. // 业务 404(token 无效)而不是路由 404(路径没匹配上)——后者说明测试自己的 token 格式写错了
  65. if (str_contains((string) ($res['body']['detail'] ?? ''), 'could not be found')) {
  66. fail('password-resets show 不存在:拿到的是路由 404,检查 AUTH_UNKNOWN_RESET_TOKEN 是否正好 64 位字母数字');
  67. }
  68. });
  69. test('GET /v3/password-resets/{token}(格式不对)→ 404', function () {
  70. // 路由约束是 64 位字母数字,匹配不上任何路由 → 404(不是 422)
  71. $res = raw('GET', '/v3/password-resets/short-token');
  72. assert_status(404, $res, 'password-resets show 格式不对');
  73. assert_problem($res, 'password-resets show 格式不对');
  74. });
  75. test('PATCH /v3/password-resets/{token}(不存在)→ 404', function () {
  76. $body = new PatchApiV3PasswordResetsTokenRequest();
  77. $body->setPassword('api-test-pw');
  78. $body->setPasswordConfirmation('api-test-pw');
  79. $res = request(fn () => api(PasswordResetsApi::class)->patchApiV3PasswordResetsTokenWithHttpInfo(AUTH_UNKNOWN_RESET_TOKEN, $body));
  80. assert_status(404, $res, 'password-resets update 不存在');
  81. assert_problem($res, 'password-resets update 不存在');
  82. });
  83. test('PATCH /v3/password-resets/{token}(两次密码不一致)→ 422 errors.password', function () {
  84. // 入参校验先于 token 查找,所以 token 不存在也是 422
  85. $res = raw('PATCH', '/v3/password-resets/' . AUTH_UNKNOWN_RESET_TOKEN, null, [
  86. 'password' => 'api-test-pw',
  87. 'password_confirmation' => 'api-test-PW',
  88. ]);
  89. assert_field_error('password', $res, 'password-resets update 确认不一致');
  90. });
  91. test('PUT /v3/password-resets/{token} → 405(只注册了 PATCH)', function () {
  92. $res = raw('PUT', '/v3/password-resets/' . AUTH_UNKNOWN_RESET_TOKEN, null, []);
  93. assert_status(405, $res, 'password-resets PUT');
  94. });
  95. /* ---- /v3/email-certifications(只测校验失败:成功路径会真的发邮件)---- */
  96. test('POST /v3/email-certifications(邮箱格式错)→ 422 errors.email', function () {
  97. $res = raw('POST', '/v3/email-certifications', null, ['email' => auth_malformed_email()]);
  98. assert_field_error('email', $res, 'email-certifications 邮箱格式错');
  99. });
  100. test('POST /v3/email-certifications(已注册邮箱)→ 422 errors.email', function () {
  101. $email = fixtures()['registered_email'] ?? null;
  102. if (! $email) {
  103. skip('fixtures.php 未配置 registered_email');
  104. }
  105. $res = raw('POST', '/v3/email-certifications', null, ['email' => $email]);
  106. assert_field_error('email', $res, 'email-certifications 已注册邮箱');
  107. });
  108. /* ---- /v3/invites ---- */
  109. test('POST /v3/invites(没发过码的邮箱)→ 422 errors.code', function () {
  110. $body = new PostApiV3InvitesRequest();
  111. $body->setEmail(auth_unregistered_email());
  112. $body->setCode('000000');
  113. $res = request(fn () => api(InvitesApi::class)->postApiV3InvitesWithHttpInfo($body));
  114. assert_field_error('code', $res, 'invites 无效验证码');
  115. });
  116. test('POST /v3/invites(验证码不是 6 位数字)→ 422 errors.code', function () {
  117. $res = raw('POST', '/v3/invites', null, ['email' => auth_unregistered_email(), 'code' => '12ab']);
  118. assert_field_error('code', $res, 'invites 验证码格式错');
  119. });
  120. test('GET /v3/invites/{invite}(不存在)→ 404', function () {
  121. $res = request(fn () => api(InvitesApi::class)->getApiV3InvitesInviteWithHttpInfo('00000000-0000-4000-8000-000000000000'));
  122. assert_status(404, $res, 'invites show 不存在');
  123. assert_problem($res, 'invites show 不存在');
  124. });
  125. test('GET /v3/invites/{invite}(不是 uuid)→ 404', function () {
  126. $res = raw('GET', '/v3/invites/not-a-uuid');
  127. assert_status(404, $res, 'invites show 非 uuid');
  128. assert_problem($res, 'invites show 非 uuid');
  129. });
  130. /* ---- /v3/users(只用不存在的 invite:不会建出账号)---- */
  131. test('POST /v3/users(invite 不存在)→ 422 errors.invite', function () {
  132. $body = new PostApiV3UsersRequest();
  133. $body->setInvite('00000000-0000-4000-8000-000000000000');
  134. $body->setUsername('apitest_' . bin2hex(random_bytes(4)));
  135. $body->setPassword('api-test-pw');
  136. $body->setPasswordConfirmation('api-test-pw');
  137. $body->setLang('zh-Hans');
  138. $res = request(fn () => api(UsersApi::class)->postApiV3UsersWithHttpInfo($body));
  139. assert_field_error('invite', $res, 'users invite 不存在');
  140. });
  141. test('POST /v3/users(字段不合法)→ 422,逐字段报错', function () {
  142. $res = raw('POST', '/v3/users', null, [
  143. 'invite' => 'not-a-uuid',
  144. 'username' => 'abc中文',
  145. 'password' => '123',
  146. 'password_confirmation' => '456',
  147. ]);
  148. foreach (['invite', 'username', 'password', 'lang'] as $field) {
  149. assert_field_error($field, $res, "users 字段不合法({$field})");
  150. }
  151. });
  152. /* ---- /v3/sessions、/v3/me ---- */
  153. // 登录按「账号 + IP」每分钟限 5 次:带账号跑时 run.php 已经登录过一次,这里再登一次,
  154. // 错误密码只用不存在的账号(不占测试账号的额度)。
  155. test('POST /v3/sessions(正确账号)→ 201 {token, user}', function () {
  156. $username = config()['username'] ?? null;
  157. $password = config()['password'] ?? null;
  158. if (! $username || ! $password) {
  159. skip('未给 --username/--password');
  160. }
  161. $body = new PostApiV3SessionsRequest();
  162. $body->setLogin($username);
  163. $body->setPassword($password);
  164. $res = request(fn () => api(SessionsApi::class)->postApiV3SessionsWithHttpInfo($body));
  165. assert_status(201, $res, 'sessions 登录');
  166. $data = $res['body']['data'] ?? null;
  167. assert_has_key('token', $data, 'sessions.data');
  168. assert_has_key('user', $data, 'sessions.data');
  169. foreach (['id', 'nickName', 'userName', 'roles', 'email'] as $key) {
  170. assert_has_key($key, $data['user'], 'sessions.data.user');
  171. }
  172. if (array_key_exists('password', $data['user'])) {
  173. fail('sessions.data.user 不应包含 password');
  174. }
  175. });
  176. test('POST /v3/sessions(账号不存在)→ 422 errors.login', function () {
  177. $res = raw('POST', '/v3/sessions', null, [
  178. 'login' => 'apitest_nobody_' . bin2hex(random_bytes(4)),
  179. 'password' => 'whatever',
  180. ]);
  181. assert_field_error('login', $res, 'sessions 账号不存在');
  182. });
  183. test('POST /v3/sessions(缺字段)→ 422 errors.login + errors.password', function () {
  184. $res = raw('POST', '/v3/sessions', null, []);
  185. assert_field_error('login', $res, 'sessions 缺字段');
  186. assert_field_error('password', $res, 'sessions 缺字段');
  187. });
  188. test('GET /v3/me(无 token)→ 401', function () {
  189. $res = request(fn () => api(MeApi::class)->getApiV3MeWithHttpInfo());
  190. assert_status(401, $res, 'me 无 token');
  191. assert_problem($res, 'me 无 token');
  192. });
  193. test('GET /v3/me(带 token)→ 200,不回显 token', function () {
  194. $res = request(fn () => api(MeApi::class, required_token())->getApiV3MeWithHttpInfo());
  195. assert_status(200, $res, 'me');
  196. $data = $res['body']['data'] ?? null;
  197. foreach (['id', 'nickName', 'userName', 'realName', 'avatar', 'roles', 'email'] as $key) {
  198. assert_has_key($key, $data, 'me.data');
  199. }
  200. if (array_key_exists('token', $data)) {
  201. fail('me.data 不应回显 token(v2 auth/current 会)');
  202. }
  203. });
  204. /* ---- 语言协商(v3 按 Accept-Language 本地化 detail / errors,title 保持英文)---- */
  205. // 探针用「错验证码换 invite」:不写库、不发信,走 sign-up 限流(同 IP 每分钟 20 次),
  206. // 不占登录的「账号 + IP」额度;返回的是 messages.php 里的文案。
  207. /** 用指定 Accept-Language 发一次错验证码请求 */
  208. function auth_wrong_code_in(string $acceptLanguage): array
  209. {
  210. return raw('POST', '/v3/invites', null, [
  211. 'email' => auth_unregistered_email(),
  212. 'code' => '000000',
  213. ], null, ['Accept-Language' => $acceptLanguage]);
  214. }
  215. test('Accept-Language: zh-Hans → 中文错误文案 + Content-Language', function () {
  216. $res = auth_wrong_code_in('zh-Hans');
  217. assert_field_error('code', $res, 'invites 错验证码(zh-Hans)');
  218. assert_eq('验证码不正确或已过期。', $res['body']['errors']['code'][0] ?? null, 'errors.code');
  219. assert_eq('zh-Hans', $res['headers']['Content-Language'][0] ?? null, 'Content-Language');
  220. });
  221. test('Accept-Language: zh-TW → 繁体;不支持的语言 → en', function () {
  222. $hant = auth_wrong_code_in('zh-TW');
  223. assert_eq('驗證碼不正確或已過期。', $hant['body']['errors']['code'][0] ?? null, 'zh-TW 的 errors.code');
  224. $fr = auth_wrong_code_in('fr-FR');
  225. assert_eq('en', $fr['headers']['Content-Language'][0] ?? null, 'fr-FR 的 Content-Language');
  226. });