Compare commits

...

3 Commits

Author SHA1 Message Date
a620906209 ca5b843362 chore(openspec): 歸檔 auth-tests 變更並建立 auth-test-coverage 主規格
Run Tests / test (pull_request) Failing after 43s
34 項驗收情境與測試已 1:1 對應且全數通過,將變更移至
openspec/changes/archive/2026-06-07-auth-tests/,並依其內容建立
openspec/specs/auth-test-coverage/spec.md 作為主規格文件。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:02:05 +08:00
a620906209 6f446f601f test(auth): 新增三角色登入/註冊/登出、P2 帳號鎖定、OAuth state 驗證測試
新增 34 個 Feature 測試(AuthLoginTest 19、AuthLockoutTest 11、
AuthOAuthTest 4)涵蓋 member/provider/admin 的註冊、登入、登出與跨角色
隔離;P2 帳號鎖定的 Fixed Window 計數、companion cache key 與 email
正規化;以及 P2 OAuth state 的 CSRF 驗證與一次性消耗。

同時新增 .gitea/workflows/test.yml 在 push/PR 時於 CI 自動執行測試
(含 GD 等擴充套件安裝),並補上對應的 OpenSpec 變更文件
(proposal/design/spec/tasks,34 項驗收情境與測試 1:1 對應)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:01:33 +08:00
a620906209 0dabc4ebb5 fix(test): 修正 AuthRateLimitTest 過期的 throttle 門檻斷言
commit 2a0e9255(P2 帳號鎖定上線)已將 member/provider 登入的 IP throttle
從 5,1 調整為 10,1(避免 IP throttle 搶在帳號鎖定的 423 之前觸發 429),
但測試的迴圈次數與註解仍停留在舊版 5,1,導致預期 429 卻收到 401。
同步將迴圈次數改為 10、區塊註解改為 throttle:10,1,使其對齊現行設定。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:01:20 +08:00
11 changed files with 1188 additions and 5 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Run Tests
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, dom, fileinfo, gd, pdo_sqlite, sqlite3
coverage: none
- name: Copy .env
run: cp .env.example .env
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Generate application key
run: php artisan key:generate
- name: Run tests
run: php artisan test
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-07
@@ -0,0 +1,60 @@
## Context
Auth 模組已實作三角色(member / provider / admin)的登入、註冊、登出,以及 P2 安全強化(帳號鎖定、OAuth state 驗證)。目前 `tests/Feature/` 只有 `AuthRateLimitTest`throttle)和 `TokenRefreshTest`refresh token),主流程與安全邏輯完全無覆蓋。
測試環境:`phpunit.xml` 已設定 `DB_CONNECTION=sqlite``DB_DATABASE=:memory:`Cache 使用 `array` driverQueue 使用 `sync`
## Goals / Non-Goals
**Goals:**
- 補齊登入/註冊/登出三角色的 happy path 與 guard 測試
- 覆蓋 P2 帳號鎖定邏輯(Fixed Window、Email 正規化、role 隔離)
- 覆蓋 P2 OAuth state 驗證(state 缺失 / 不符 / 正確)
- 每個測試檔案獨立、可單獨執行
**Non-Goals:**
- 不測試 Token Refresh(已有 `TokenRefreshTest`
- 不測試 Rate Limiting(已有 `AuthRateLimitTest`
- 不測試前端 Vue 元件
- 不測試 Admin CRUD(屬於別的 change 範疇)
## Decisions
### 決策 1:三支測試檔案,各自對應一個關注點
- `AuthLoginTest.php`:登入 / 註冊 / 登出三角色的 happy path + guard
- `AuthLockoutTest.php`P2 帳號鎖定邏輯
- `AuthOAuthTest.php`P2 OAuth state 驗證
**理由**:單一檔案職責清晰,出錯時可快速定位;不同測試的 setUp 需求差異大(Lockout 需 Cache::flush、OAuth 需 Socialite mock)。
### 決策 2Lockout 測試使用 Cache facade,不 mock
phpunit.xml 已設 `CACHE_STORE=array`Cache 行為完全真實。
`setUp()` 呼叫 `Cache::flush()` 確保測試間隔離。
**理由**Lockout 邏輯的 bug 通常出在 Cache key 命名或 TTL 操作,用真實 array cache 才能抓到;mock 只會讓測試通過而 bug 繼續存在。
### 決策 3OAuth 測試使用 Socialite mock,並以 shouldNotReceive 驗證安全不變式
OAuth callback 依賴 Google 回應,測試環境無法真實呼叫,使用 Socialite Facade mock。
Mock 在 OAuth 測試中有兩個用途:
- **state 正確時**`shouldReceive('driver->user')` 回傳假 Google user,讓登入流程走完
- **state 缺失 / 不符時**`shouldNotReceive('driver->user')` 確認 Socialite 不被呼叫
第二個用途是這組測試的核心安全不變式:**CSRF 檢查失敗時,系統不得向 OAuth provider 取 user**。如果只斷言 redirect URL 含 `error=oauth_failed` 而不驗證 Socialite 未被呼叫,測試無法偵測「state 檢查失敗但仍繼續執行 OAuth 流程」這類回歸。
**理由**OAuth state 的設計意圖不只是「最後回錯誤」,而是在 CSRF 檢查點提前中止整個流程;`shouldNotReceive` 是唯一能驗證這個 invariant 的手段。
### 決策 4:共用 helper 方法直接定義在各測試 class 內
三支測試各自有 `createMember()``createProvider()``createAdmin()` 等 helper。
**理由**:測試共 3 個檔案,規模不大,不值得抽 BaseTestCase。一旦需要改 helper 邏輯,在各自檔案改更直觀,不會影響其他測試。
## Risks / Trade-offs
- **OAuth redirect 比對**`handleGoogleCallback` 最終用 `redirect()` 回前端 URL,測試需用 `assertRedirect()` 而非 `assertStatus()`URL 中含 `error=oauth_failed` 做斷言。→ 使用 `assertRedirectContains('error=oauth_failed')` 避免硬寫前端 URL。
- **Lockout TTL 在測試中**array cache 不適合可靠驗證時間流逝後的自動過期,因此不測 cache driver 的 TTL 行為本身。測試只驗證「當 lockout cache entry 不存在時,登入流程恢復允許」;清除狀態以 `Cache::forget()` 模擬,不等待真實時間。這不等同於「TTL 到期自動解鎖」的驗證,兩者不可混淆。
- **Admin 帳號在鎖定測試的範圍**AuthController 的 `loginAdmin` 沒有帳號鎖定邏輯(P2 只加了 member/provider),不在 AuthLockoutTest 測試範圍。→ 在 AuthLoginTest 只測 Admin 的 happy path 與角色 guard。
## Open Questions
- 無(設計已足夠完整開始實作)
@@ -0,0 +1,25 @@
## Why
Auth 模組(登入/註冊/登出、P2 帳號鎖定、P2 OAuth state 驗證)已實作並上線,但目前完全沒有測試覆蓋登入主流程與安全邏輯,任何回歸都會靜默失效。
## What Changes
- 新增 `tests/Feature/AuthLoginTest.php`:覆蓋 member / provider / admin 三角色的登入、註冊、登出、非活躍帳號拒絕、角色隔離等場景
- 新增 `tests/Feature/AuthLockoutTest.php`:覆蓋 P2 帳號鎖定的全部 scenarioFixed Window 計數、第 5 次觸發、鎖定期間拒絕、成功後清除、Email 正規化、role 隔離)
- 新增 `tests/Feature/AuthOAuthTest.php`:覆蓋 P2 OAuth state 驗證(state 不符回 `/login?error=oauth_failed`、state 正確完成登入)
## Capabilities
### New Capabilities
- `auth-test-coverage`:定義 Auth 模組所有必要測試場景的規格,作為實作三支測試檔案的依據
### Modified Capabilities
(無,所有需求已定義於既有 spec;本 change 只新增驗證覆蓋)
## Impact
- 僅新增測試檔案,不修改任何 production 程式碼
- 依賴:`phpunit.xml` 已設定 `DB_CONNECTION=sqlite``DB_DATABASE=:memory:`,可直接使用
- 影響範圍:`tests/Feature/`(新增三個檔案)
@@ -0,0 +1,151 @@
## ADDED Requirements
### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色)
測試套件 SHALL 對 member、provider、admin 三個角色各自驗證以下場景,確保回歸時能即時偵測。
#### Scenario: Member 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/member/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 member 用戶
#### Scenario: Member 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/member/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Member 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/member/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Member 登入密碼錯誤
- **WHEN** 送出存在的 email 但錯誤的 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`
#### Scenario: Member 非活躍帳號拒絕登入
- **WHEN** 送出 is_active=false 帳號的正確帳密
- **THEN** 回傳 HTTP 403
#### Scenario: Provider 帳號無法呼叫 Member 登入
- **WHEN** role=provider 的帳號嘗試 `POST /api/member/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在,不洩漏帳號實際所屬角色)
#### Scenario: Member 登出成功
- **WHEN** 已登入 Member 送出 `POST /api/member/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Provider 登出成功
- **WHEN** 已登入 Provider 送出 `POST /api/provider/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Admin 登出成功
- **WHEN** 已登入 Admin 送出 `POST /api/admin/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Provider 帳號無法呼叫 Admin 登入
- **WHEN** role=provider 的帳號嘗試 `POST /api/admin/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
#### Scenario: Provider 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/provider/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 provider 用戶
#### Scenario: Provider 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/provider/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/provider/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Provider 非活躍帳號拒絕登入
- **WHEN** 送出 is_active=false 的 provider 帳號正確帳密
- **THEN** 回傳 HTTP 403
#### Scenario: Member 帳號無法呼叫 Provider 登入
- **WHEN** role=member 的帳號嘗試 `POST /api/provider/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
#### Scenario: Admin 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/admin/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 admin 用戶
#### Scenario: Admin 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/admin/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Admin 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/admin/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Admin 登入非 admin 帳號失敗
- **WHEN** role=member 帳號嘗試 `POST /api/admin/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
---
### Requirement: 帳號鎖定測試覆蓋(P2 Fixed Window
測試套件 SHALL 驗證帳號鎖定邏輯的全部關鍵路徑,以防止回歸破壞安全機制。
#### Scenario: 失敗次數未達閾值不鎖定
- **WHEN** 同一帳號連續登入失敗 4 次
- **THEN** 每次均回傳 HTTP 401,帳號未鎖定
#### Scenario: 第 5 次失敗當場觸發鎖定
- **WHEN** 同一帳號連續登入失敗第 5 次
- **THEN** 回傳 HTTP 423body 包含 `{ status: false, locked_until: "<ISO8601>" }`
#### Scenario: 鎖定期間任何登入均被拒絕
- **WHEN** 帳號已鎖定,使用者送出(含正確密碼的)登入請求
- **THEN** 回傳 HTTP 423,不驗證密碼
#### Scenario: 不存在帳號不累計失敗計數
- **WHEN** 以不存在的 email 連續嘗試登入 10 次
- **THEN** 每次均回傳 HTTP 401Cache 中不建立計數 key,第 10 次後帳號不鎖定
#### Scenario: 登入成功後清除失敗計數
- **WHEN** 帳號失敗 3 次後成功登入,再失敗 4 次
- **THEN** 失敗計數從成功後重新累積,第 4 次仍回傳 401(不觸發鎖定)
#### Scenario: Email 大小寫視為同一帳號
- **WHEN** 同一帳號分別用 `user@example.com` 失敗 3 次、`User@Example.COM` 失敗 2 次
- **THEN** 第 5 次失敗(累計)觸發鎖定,回傳 HTTP 423
#### Scenario: Email 前後空白視為同一帳號
- **WHEN** 同一帳號分別用 `user@example.com` 失敗 3 次、` user@example.com `(含前後空白)失敗 2 次
- **THEN** 第 5 次失敗(累計)觸發鎖定,回傳 HTTP 423
#### Scenario: 鎖定 cache entry 不存在時帳號恢復可登入
- **WHEN** 帳號已鎖定,鎖定的 cache entry 被移除(模擬 TTL 自然過期)
- **THEN** 帳號可正常登入,回傳 HTTP 200
#### Scenario: 跨角色嘗試不會汙染或鎖定正確角色帳號
> 註:原情境假設「同一 email 同時擁有 member 與 provider 帳號」,但 `users.email` 在 DB 層級為全域 unique`database/migrations/2025_05_06_065906_create_orgin_table.php:18`),此前提不可能成立,故改寫為下列可達成、且驗證相同安全性質(角色間鎖定計數互相隔離)的版本。
- **WHEN** 一個僅存在 provider 帳號的 email,先對 `/api/member/login` 送出 4 次失敗嘗試,再對 `/api/provider/login` 送出 4 次失敗嘗試
- **THEN** member namespace 不建立計數 key(帳號不存在於該 namespace);provider namespace 計數累積為 4、未達閾值不鎖定;provider 帳號仍可用正確密碼正常登入
#### Scenario: Fixed Window — 失敗不延長 TTL
- **WHEN** 帳號失敗 4 次後,第 4 次失敗發生於 TTL 將到期前
- **THEN** Cache TTL 不被重設,`locked_until` 仍為第一次失敗時寫入的時間
#### Scenario: `locked_until` 來自 companion key
- **WHEN** 帳號觸發鎖定,回傳 HTTP 423
- **THEN** response body 的 `locked_until` 等於 `login_expires_at:{role}:{email}` cache key 的值
---
### Requirement: OAuth State 驗證測試覆蓋(P2
測試套件 SHALL 驗證 OAuth callback 的 state 比對邏輯,涵蓋正常、缺失、不符三種情境。
#### Scenario: State 缺失時 redirect 至錯誤頁,且不呼叫 Socialite
- **WHEN** 直接呼叫 `GET /auth/google/callback` 而不帶 `state` 參數(session 無 `oauth_state`
- **THEN** 後端 redirect 至包含 `error=oauth_failed` 的 URL,不繼續登入流程;Socialite 的 `user()` 不被呼叫
#### Scenario: State 不符時 redirect 至錯誤頁,且不呼叫 Socialite
- **WHEN** callback 帶 `state=wrong_value`,但 session 中 `oauth_state` 為不同值
- **THEN** 後端 redirect 至包含 `error=oauth_failed` 的 URLSocialite 的 `user()` 不被呼叫
#### Scenario: State 正確時完成 OAuth 登入
- **WHEN** callback 帶的 `state` 與 session 中 `oauth_state` 一致,且 Socialite 回傳有效 Google user
- **THEN** 後端完成登入,redirect 至前端並附帶 tokenURL fragment `#token=...`
#### Scenario: State 成功驗證後從 session 消耗
- **WHEN** callback 帶正確 `state` 完成一次登入後,再次以相同 `state` 呼叫 callback
- **THEN** session 中已無 `oauth_state`,視為 state 缺失,redirect 至 `error=oauth_failed`
@@ -0,0 +1,56 @@
## 1. AuthLoginTest — 三角色登入/註冊/登出
- [x] 1.1 [整合測試] 建立 `tests/Feature/AuthLoginTest.php`,加入 `RefreshDatabase` trait 與 helper methods`createMember``createProvider``createAdmin`
- [x] 1.2 [整合測試] 實作 `test_member_register_success`POST `/api/member/register` 回傳 201 + DB 有對應 member 用戶
- [x] 1.3 [整合測試] 實作 `test_member_register_duplicate_email_returns_422`:重複 email 回傳 422
- [x] 1.4 [整合測試] 實作 `test_member_login_success`:正確帳密回傳 200 + token
- [x] 1.5 [整合測試] 實作 `test_member_login_wrong_password_returns_401`:錯誤密碼回傳 401
- [x] 1.6 [整合測試] 實作 `test_member_login_inactive_account_returns_403`is_active=false 回傳 403
- [x] 1.7 [整合測試] 實作 `test_provider_account_cannot_use_member_login`role=provider 帳號打 member login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.8 [整合測試] 實作 `test_member_logout_revokes_token`:登出後 token 失效(DB 確認 token 已刪除),且以同一 token 再打 `/api/member/profile` 得 401(需先 `Auth::forgetGuards()` 清除 `RequestGuard` 在同一測試內快取的已解析 user,否則會讀到快取而誤判為 200)
- [x] 1.9 [整合測試] 實作 `test_provider_logout_revokes_token`Provider 登出後 token 失效,且以同一 token 再打 `/api/provider/profile` 得 401(同樣需 `Auth::forgetGuards()`
- [x] 1.10 [整合測試] 實作 `test_admin_logout_revokes_token`Admin 登出後 token 失效,且以同一 token 再打 `/api/admin/profile` 得 401(同樣需 `Auth::forgetGuards()`
- [x] 1.12 [整合測試] 實作 `test_provider_login_success`Provider 正確帳密回傳 200 + token
- [x] 1.13 [整合測試] 實作 `test_provider_login_inactive_account_returns_403`is_active=false 回傳 403
- [x] 1.14 [整合測試] 實作 `test_member_account_cannot_use_provider_login`role=member 帳號打 provider login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.15 [整合測試] 實作 `test_admin_login_success`Admin 正確帳密回傳 200 + token
- [x] 1.16 [整合測試] 實作 `test_member_cannot_use_admin_login`role=member 帳號打 admin login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.17 [整合測試] 實作 `test_provider_cannot_use_admin_login`role=provider 帳號打 admin login 回傳 401(查詢以 role 過濾,跨角色視同帳號不存在)
- [x] 1.18 [整合測試] 實作 `test_provider_register_success`POST `/api/provider/register` 回傳 201 + DB 有對應 provider 用戶(design.md 載明三角色註冊均須覆蓋,原 1.x 僅含 member 註冊測試,此處補齊)
- [x] 1.19 [整合測試] 實作 `test_provider_register_duplicate_email_returns_422`:重複 email 回傳 422
- [x] 1.20 [整合測試] 實作 `test_admin_register_success`POST `/api/admin/register` 回傳 201 + DB 有對應 admin 用戶
- [x] 1.21 [整合測試] 實作 `test_admin_register_duplicate_email_returns_422`:重複 email 回傳 422
## 2. AuthLockoutTest — P2 帳號鎖定
- [x] 2.1 [整合測試] 建立 `tests/Feature/AuthLockoutTest.php``setUp()` 加入 `Cache::flush()` 確保測試間隔離
- [x] 2.2 [整合測試] 實作 `test_four_failures_do_not_lock_account`:同一帳號失敗 4 次均回傳 401,帳號未鎖定
- [x] 2.3 [整合測試] 實作 `test_fifth_failure_triggers_423_with_locked_until`:第 5 次失敗回傳 423,body 含 `locked_until` ISO 8601 欄位
- [x] 2.4 [整合測試] 實作 `test_locked_account_rejects_correct_password`:鎖定後送出正確密碼仍回傳 423
- [x] 2.5 [整合測試] 實作 `test_nonexistent_email_does_not_increment_counter`:不存在 email 失敗 10 次後 Cache 無計數 key,不觸發鎖定
- [x] 2.6 [整合測試] 實作 `test_successful_login_clears_failure_counter`:失敗 3 次後成功登入,再失敗 4 次仍回 401(計數從 0 重算)
- [x] 2.7 [整合測試] 實作 `test_email_case_normalization_counts_same_account`:大小寫變體 email 累計失敗到同一 key,第 5 次觸發鎖定
- [x] 2.8 [整合測試] 實作 `test_email_trim_normalization_counts_same_account`:前後空白 email 累計失敗到同一 key,第 5 次觸發鎖定
- [x] 2.9 [整合測試] 實作 `test_member_login_attempts_do_not_affect_provider_lockout_for_same_email`:僅存在 provider 帳號的 email,對 member login 失敗 4 次(不增加計數)+ 對 provider login 失敗 4 次(計數=4,未鎖定,仍可正常登入)。原情境因 `users.email` 全域 unique 無法同時擁有 member+provider 帳號,已改寫並同步更新 spec.md
- [x] 2.10 [整合測試] 實作 `test_account_can_login_after_lockout_entry_removed`:鎖定後 `Cache::forget()` 移除 entry,帳號恢復可登入(回傳 200)
- [x] 2.11 [整合測試] 實作 `test_locked_until_comes_from_companion_cache_key`423 response 的 `locked_until` 等於 `login_expires_at:member:{email}` cache key 的值
- [x] 2.12 [整合測試] 實作 `test_repeated_failures_do_not_extend_lockout_window`:對應 spec.md「Fixed Window — 失敗不延長 TTL」情境(spec 已定義但原 tasks 未列出,此處補上)。第 1 次失敗寫入 `login_expires_at` 後,第 2~4 次失敗不應更動該值(驗證 `recordLoginFailure` 只在計數 key 不存在時才寫入 companion key,不隨後續失敗重設視窗)
## 3. AuthOAuthTest — P2 OAuth State 驗證
- [x] 3.1 [整合測試] 建立 `tests/Feature/AuthOAuthTest.php`,加入 `RefreshDatabase` trait;引入 `Laravel\Socialite\Facades\Socialite`
- [x] 3.2 [整合測試] 實作 `test_oauth_callback_without_state_redirects_to_error`:不帶 state 參數、session 無 `oauth_state`,斷言 redirect 含 `error=oauth_failed`,且 Socialite `user()` 不被呼叫(使用 `shouldNotReceive`
- [x] 3.3 [整合測試] 實作 `test_oauth_callback_with_wrong_state_redirects_to_error`session 設 `oauth_state=correct`,帶 `state=wrong`,斷言 redirect 含 `error=oauth_failed`,且 Socialite `user()` 不被呼叫
- [x] 3.4 [整合測試] 實作 `test_oauth_callback_with_correct_state_completes_login`session 設 `oauth_state=abc`,帶 `state=abc`Socialite mock 回傳假 Google user,斷言 redirect URL 含 `#token=`
- [x] 3.5 [整合測試] 實作 `test_oauth_state_is_consumed_after_successful_callback`:正確 state 完成一次登入後,再以相同 state 呼叫 callback,斷言第二次 redirect 含 `error=oauth_failed`state 一次性消耗)
## 4. 驗收確認
- [x] 4.1 [整合測試] 執行 `php artisan test --filter AuthLoginTest` 全數通過,無 skip19 passed / 60 assertions
- [x] 4.2 [整合測試] 執行 `php artisan test --filter AuthLockoutTest` 全數通過,無 skip11 passed / 44 assertions
- [x] 4.3 [整合測試] 執行 `php artisan test --filter AuthOAuthTest` 全數通過,無 skip4 passed / 12 assertions
- [x] 4.4 [整合測試] 在容器內執行 `docker exec cfdive-app php artisan test`host PHP 缺 GD 擴充套件,須在容器內跑才能涵蓋 `CourseImageTest`),結果:**98 passed / 256 assertions,全數通過,無回歸**。本次新增的 Auth 測試均已通過:AuthLoginTest 19、AuthLockoutTest 11、AuthOAuthTest 4(合計 34OAuth 部分已於 4.3 單獨驗證)
> **過程中排除的兩類既存失敗(均已解決,非本次改動引入)**
> - `CourseImageTest`11 個):原本在 host PHP 跑會因缺 GD 擴充套件報 `LogicException: GD extension is not installed.`;確認專案 `Dockerfile` 已內建 GD,改用 `docker exec cfdive-app php artisan test` 在容器內執行即全數通過 —— 純屬執行環境差異,非程式或測試邏輯問題,往後請統一在容器內跑測試
> - `AuthRateLimitTest`2 個):`member/provider login exceeds limit returns 429` 原本預期 429 實得 401。根因是過期測試——commit `2a0e9255`(P2 帳號鎖定上線時)刻意將 member/provider 的 IP throttle 從 `5,1` 調整為 `10,1`(避免 IP throttle 搶在帳號鎖定前觸發),但測試的迴圈次數與註解仍停留在舊版 `5,1`。已同步修正測試的迴圈次數(5→10)與區塊註解,使其對齊現行 `throttle:10,1` 設定,修正後全數通過
+155
View File
@@ -0,0 +1,155 @@
# auth-test-coverage Specification
## Purpose
TBD - created by archiving change auth-tests. Update Purpose after archive.
## Requirements
### Requirement: Auth 登入/註冊/登出測試覆蓋(三角色)
測試套件 SHALL 對 member、provider、admin 三個角色各自驗證以下場景,確保回歸時能即時偵測。
#### Scenario: Member 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/member/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 member 用戶
#### Scenario: Member 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/member/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Member 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/member/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Member 登入密碼錯誤
- **WHEN** 送出存在的 email 但錯誤的 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`
#### Scenario: Member 非活躍帳號拒絕登入
- **WHEN** 送出 is_active=false 帳號的正確帳密
- **THEN** 回傳 HTTP 403
#### Scenario: Provider 帳號無法呼叫 Member 登入
- **WHEN** role=provider 的帳號嘗試 `POST /api/member/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在,不洩漏帳號實際所屬角色)
#### Scenario: Member 登出成功
- **WHEN** 已登入 Member 送出 `POST /api/member/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Provider 登出成功
- **WHEN** 已登入 Provider 送出 `POST /api/provider/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Admin 登出成功
- **WHEN** 已登入 Admin 送出 `POST /api/admin/logout`(帶有效 Bearer token
- **THEN** 回傳 HTTP 200token 被撤銷(後續以此 token 請求受保護資源應得 401)
#### Scenario: Provider 帳號無法呼叫 Admin 登入
- **WHEN** role=provider 的帳號嘗試 `POST /api/admin/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
#### Scenario: Provider 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/provider/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 provider 用戶
#### Scenario: Provider 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/provider/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Provider 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/provider/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Provider 非活躍帳號拒絕登入
- **WHEN** 送出 is_active=false 的 provider 帳號正確帳密
- **THEN** 回傳 HTTP 403
#### Scenario: Member 帳號無法呼叫 Provider 登入
- **WHEN** role=member 的帳號嘗試 `POST /api/provider/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
#### Scenario: Admin 註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation 至 `POST /api/admin/register`
- **THEN** 回傳 HTTP 201body 包含 `{ status: true, data: { user } }`DB 存在對應 admin 用戶
#### Scenario: Admin 重複 Email 註冊失敗
- **WHEN** 送出已存在的 email 至 `POST /api/admin/register`
- **THEN** 回傳 HTTP 422
#### Scenario: Admin 登入成功
- **WHEN** 送出正確的 email / password 至 `POST /api/admin/login`
- **THEN** 回傳 HTTP 200body 包含 `{ status: true, data: { token, token_type: "Bearer", user } }`
#### Scenario: Admin 登入非 admin 帳號失敗
- **WHEN** role=member 帳號嘗試 `POST /api/admin/login`
- **THEN** 回傳 HTTP 401`{ status: false, message: "電子郵件或密碼錯誤" }`(查詢以 role 過濾,跨角色帳號視同不存在)
---
### Requirement: 帳號鎖定測試覆蓋(P2 Fixed Window
測試套件 SHALL 驗證帳號鎖定邏輯的全部關鍵路徑,以防止回歸破壞安全機制。
#### Scenario: 失敗次數未達閾值不鎖定
- **WHEN** 同一帳號連續登入失敗 4 次
- **THEN** 每次均回傳 HTTP 401,帳號未鎖定
#### Scenario: 第 5 次失敗當場觸發鎖定
- **WHEN** 同一帳號連續登入失敗第 5 次
- **THEN** 回傳 HTTP 423body 包含 `{ status: false, locked_until: "<ISO8601>" }`
#### Scenario: 鎖定期間任何登入均被拒絕
- **WHEN** 帳號已鎖定,使用者送出(含正確密碼的)登入請求
- **THEN** 回傳 HTTP 423,不驗證密碼
#### Scenario: 不存在帳號不累計失敗計數
- **WHEN** 以不存在的 email 連續嘗試登入 10 次
- **THEN** 每次均回傳 HTTP 401Cache 中不建立計數 key,第 10 次後帳號不鎖定
#### Scenario: 登入成功後清除失敗計數
- **WHEN** 帳號失敗 3 次後成功登入,再失敗 4 次
- **THEN** 失敗計數從成功後重新累積,第 4 次仍回傳 401(不觸發鎖定)
#### Scenario: Email 大小寫視為同一帳號
- **WHEN** 同一帳號分別用 `user@example.com` 失敗 3 次、`User@Example.COM` 失敗 2 次
- **THEN** 第 5 次失敗(累計)觸發鎖定,回傳 HTTP 423
#### Scenario: Email 前後空白視為同一帳號
- **WHEN** 同一帳號分別用 `user@example.com` 失敗 3 次、` user@example.com `(含前後空白)失敗 2 次
- **THEN** 第 5 次失敗(累計)觸發鎖定,回傳 HTTP 423
#### Scenario: 鎖定 cache entry 不存在時帳號恢復可登入
- **WHEN** 帳號已鎖定,鎖定的 cache entry 被移除(模擬 TTL 自然過期)
- **THEN** 帳號可正常登入,回傳 HTTP 200
#### Scenario: 跨角色嘗試不會汙染或鎖定正確角色帳號
> 註:原情境假設「同一 email 同時擁有 member 與 provider 帳號」,但 `users.email` 在 DB 層級為全域 unique`database/migrations/2025_05_06_065906_create_orgin_table.php:18`),此前提不可能成立,故改寫為下列可達成、且驗證相同安全性質(角色間鎖定計數互相隔離)的版本。
- **WHEN** 一個僅存在 provider 帳號的 email,先對 `/api/member/login` 送出 4 次失敗嘗試,再對 `/api/provider/login` 送出 4 次失敗嘗試
- **THEN** member namespace 不建立計數 key(帳號不存在於該 namespace);provider namespace 計數累積為 4、未達閾值不鎖定;provider 帳號仍可用正確密碼正常登入
#### Scenario: Fixed Window — 失敗不延長 TTL
- **WHEN** 帳號失敗 4 次後,第 4 次失敗發生於 TTL 將到期前
- **THEN** Cache TTL 不被重設,`locked_until` 仍為第一次失敗時寫入的時間
#### Scenario: `locked_until` 來自 companion key
- **WHEN** 帳號觸發鎖定,回傳 HTTP 423
- **THEN** response body 的 `locked_until` 等於 `login_expires_at:{role}:{email}` cache key 的值
---
### Requirement: OAuth State 驗證測試覆蓋(P2
測試套件 SHALL 驗證 OAuth callback 的 state 比對邏輯,涵蓋正常、缺失、不符三種情境。
#### Scenario: State 缺失時 redirect 至錯誤頁,且不呼叫 Socialite
- **WHEN** 直接呼叫 `GET /auth/google/callback` 而不帶 `state` 參數(session 無 `oauth_state`
- **THEN** 後端 redirect 至包含 `error=oauth_failed` 的 URL,不繼續登入流程;Socialite 的 `user()` 不被呼叫
#### Scenario: State 不符時 redirect 至錯誤頁,且不呼叫 Socialite
- **WHEN** callback 帶 `state=wrong_value`,但 session 中 `oauth_state` 為不同值
- **THEN** 後端 redirect 至包含 `error=oauth_failed` 的 URLSocialite 的 `user()` 不被呼叫
#### Scenario: State 正確時完成 OAuth 登入
- **WHEN** callback 帶的 `state` 與 session 中 `oauth_state` 一致,且 Socialite 回傳有效 Google user
- **THEN** 後端完成登入,redirect 至前端並附帶 tokenURL fragment `#token=...`
#### Scenario: State 成功驗證後從 session 消耗
- **WHEN** callback 帶正確 `state` 完成一次登入後,再次以相同 `state` 呼叫 callback
- **THEN** session 中已無 `oauth_state`,視為 state 缺失,redirect 至 `error=oauth_failed`
+262
View File
@@ -0,0 +1,262 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* P2 帳號鎖定機制:同一帳號連續登入失敗達 5 次就鎖定,回傳 423 + locked_until。
* 失敗次數記在 cache key `login_failures:{role}:{email}`
* 鎖定到期時間記在 companion key `login_expires_at:{role}:{email}`
* Fixed Window(固定視窗)策略:視窗只在第一次失敗時開啟、寫入到期時間,
* 之後的失敗只累加次數、不會重設或延長視窗(區別於會「越測越鎖越久」的 Sliding Window)。
*/
class AuthLockoutTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Cache 不像 DB 有 RefreshDatabase 幫忙重置,上一個測試殘留的失敗計數
// 會直接影響下一個測試的鎖定門檻判斷,所以每個測試前都要手動清空。
Cache::flush();
}
// ── helpers ─────────────────────────────────────────────
private function createMember(array $attributes = []): User
{
return User::factory()->create(array_merge([
'role' => 'member',
'is_active' => true,
'password' => Hash::make('password123'),
], $attributes));
}
private function createProvider(array $attributes = []): User
{
return User::factory()->create(array_merge([
'role' => 'provider',
'is_active' => true,
'password' => Hash::make('password123'),
], $attributes));
}
private function failMemberLogin(string $email, int $times): void
{
for ($i = 0; $i < $times; $i++) {
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password']);
}
}
// ── Fixed Window 計數 ────────────────────────────────────
public function test_four_failures_do_not_lock_account(): void
{
$email = 'lockout1@example.com';
$this->createMember(['email' => $email]);
for ($i = 0; $i < 4; $i++) {
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password'])
->assertStatus(401);
}
// 帳號未鎖定:用正確密碼仍可登入(若已鎖定,無論密碼對錯都會回傳 423)
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
->assertStatus(200);
}
public function test_fifth_failure_triggers_423_with_locked_until(): void
{
$email = 'lockout2@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 4);
$response = $this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password']);
$response->assertStatus(423)
->assertJsonPath('status', false)
->assertJsonStructure(['locked_until']);
$this->assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/',
$response->json('locked_until'),
'locked_until 應為 ISO 8601 格式'
);
}
public function test_locked_account_rejects_correct_password(): void
{
$email = 'lockout3@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 5);
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
->assertStatus(423);
}
public function test_nonexistent_email_does_not_increment_counter(): void
{
// 防的是「用不存在的 email 灌爆 cache」這種資源耗盡攻擊:
// 帳號不存在時,系統不該為它建立任何計數 key(因為帳號本來就鎖不了)。
$email = 'nosuchaccount@example.com';
for ($i = 0; $i < 10; $i++) {
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'whatever'])
->assertStatus(401);
}
$this->assertNull(Cache::get("login_failures:member:{$email}"));
}
public function test_successful_login_clears_failure_counter(): void
{
// 驗證「失敗計數會在成功登入後歸零」:避免使用者偶爾打錯密碼幾次、
// 之後成功登入了,卻因為舊的失敗次數一直累積,下次只是再打錯 1、2 次
// 就被鎖定——那會讓鎖定機制變得對正常使用者太敏感。
$email = 'lockout4@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 3);
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
->assertStatus(200);
// 計數已從 0 重新累積:再失敗 4 次仍應是 401(未達閾值)
for ($i = 0; $i < 4; $i++) {
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password'])
->assertStatus(401);
}
}
// ── Email 正規化 ─────────────────────────────────────────
//
// 如果 cache key 是用「使用者輸入的原始字串」組出來的,攻擊者只要每次
// 換個大小寫或多敲一個空白,就能讓系統誤判成不同帳號、產生新的計數 key,
// 藉此繞過鎖定機制無限期暴力破解。下面兩個測試確認系統有把 email
// 正規化(轉小寫、去頭尾空白)後再記錄,讓變體仍然落在同一個 key 上。
public function test_email_case_normalization_counts_same_account(): void
{
$email = 'normalize-case@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 3);
$this->postJson('/api/member/login', ['email' => 'Normalize-Case@EXAMPLE.COM', 'password' => 'wrong-password'])
->assertStatus(401);
// 累計第 5 次失敗(大小寫變體正規化後計入同一個 cache key)
$this->postJson('/api/member/login', ['email' => 'Normalize-Case@EXAMPLE.COM', 'password' => 'wrong-password'])
->assertStatus(423);
}
public function test_email_trim_normalization_counts_same_account(): void
{
$email = 'normalize-trim@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 3);
$this->postJson('/api/member/login', ['email' => ' normalize-trim@example.com ', 'password' => 'wrong-password'])
->assertStatus(401);
// 累計第 5 次失敗(前後空白變體正規化後計入同一個 cache key)
$this->postJson('/api/member/login', ['email' => ' normalize-trim@example.com ', 'password' => 'wrong-password'])
->assertStatus(423);
}
// ── 角色隔離 ─────────────────────────────────────────────
public function test_member_login_attempts_do_not_affect_provider_lockout_for_same_email(): void
{
// users.email 在 DB 層級全域 unique,無法同時存在同 email 的 member + provider 帳號,
// 故改驗證「對錯誤角色 endpoint 嘗試不會汙染或鎖定正確角色帳號」這個對等的安全性質。
$email = 'cross-namespace@example.com';
$this->createProvider(['email' => $email]);
// member namespace 中沒有此帳號:失敗不增加計數
$this->failMemberLogin($email, 4);
$this->assertNull(Cache::get("login_failures:member:{$email}"));
// provider namespace 中累積 4 次失敗,未達閾值
for ($i = 0; $i < 4; $i++) {
$this->postJson('/api/provider/login', ['email' => $email, 'password' => 'wrong-password'])
->assertStatus(401);
}
$this->assertSame(4, Cache::get("login_failures:provider:{$email}"));
// provider 帳號仍可用正確密碼正常登入(未被鎖定)
$this->postJson('/api/provider/login', ['email' => $email, 'password' => 'password123'])
->assertStatus(200);
}
// ── 解鎖與 companion key ─────────────────────────────────
public function test_account_can_login_after_lockout_entry_removed(): void
{
// 鎖定不是永久的——cache key 帶 TTL,到期後會自動消失。
// 測試裡沒辦法真的等待 TTL 倒數,所以用 Cache::forget 直接模擬「TTL 到期、
// entry 被清掉」之後的狀態,驗證帳號會自動恢復可登入(不需要任何人手動解鎖)。
$email = 'lockout5@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 5);
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
->assertStatus(423);
// 模擬 TTL 自然過期:移除 lockout cache entry
Cache::forget("login_failures:member:{$email}");
Cache::forget("login_expires_at:member:{$email}");
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
->assertStatus(200);
}
public function test_locked_until_comes_from_companion_cache_key(): void
{
// 確認 API 回傳給前端的 locked_until,跟後端實際拿來判斷「何時可以解鎖」
// 的 companion cache key 是同一個值——避免兩邊各算各的,導致前端顯示的
// 倒數時間跟後端真正解鎖的時間對不上。
$email = 'lockout6@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 4);
$response = $this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password']);
$response->assertStatus(423);
$expiresAtFromCache = Cache::get("login_expires_at:member:{$email}");
$this->assertNotNull($expiresAtFromCache);
$this->assertSame($expiresAtFromCache, $response->json('locked_until'));
}
public function test_repeated_failures_do_not_extend_lockout_window(): void
{
// 這條在區分 Fixed Window 與 Sliding Window 兩種策略:
// 如果每次失敗都重新計算 locked_until,攻擊者(或誤觸的使用者)只要持續嘗試,
// 鎖定視窗就會被無限往後推遲,等同於永久鎖死。Fixed Window 的作法是
// 「視窗在第一次失敗時就固定下來」,之後不論再失敗幾次,到期時間都不變。
$email = 'lockout7@example.com';
$this->createMember(['email' => $email]);
$this->failMemberLogin($email, 1);
$firstExpiresAt = Cache::get("login_expires_at:member:{$email}");
$this->assertNotNull($firstExpiresAt);
$this->failMemberLogin($email, 3);
$expiresAtAfterMoreFailures = Cache::get("login_expires_at:member:{$email}");
// Fixed Windowcompanion key 只在第一次失敗時寫入,後續失敗只遞增計數,不重設視窗
$this->assertSame($firstExpiresAt, $expiresAtAfterMoreFailures);
}
}
+344
View File
@@ -0,0 +1,344 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AuthLoginTest extends TestCase
{
use RefreshDatabase;
// ── helpers ─────────────────────────────────────────────
private function createMember(array $attributes = []): User
{
return User::factory()->create(array_merge([
'role' => 'member',
'is_active' => true,
'password' => Hash::make('password123'),
], $attributes));
}
private function createProvider(array $attributes = []): User
{
return User::factory()->create(array_merge([
'role' => 'provider',
'is_active' => true,
'password' => Hash::make('password123'),
], $attributes));
}
private function createAdmin(array $attributes = []): User
{
return User::factory()->create(array_merge([
'role' => 'admin',
'is_active' => true,
'password' => Hash::make('password123'),
], $attributes));
}
// ── member 註冊 ──────────────────────────────────────────
public function test_member_register_success(): void
{
$response = $this->postJson('/api/member/register', [
'name' => 'Test Member',
'email' => 'newmember@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201)
->assertJsonPath('status', true)
->assertJsonStructure(['data' => ['user']]);
$this->assertDatabaseHas('users', [
'email' => 'newmember@example.com',
'role' => 'member',
]);
}
public function test_member_register_duplicate_email_returns_422(): void
{
$this->createMember(['email' => 'dup@example.com']);
$response = $this->postJson('/api/member/register', [
'name' => 'Another Member',
'email' => 'dup@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(422);
}
// ── member 登入/登出 ────────────────────────────────────
public function test_member_login_success(): void
{
$this->createMember(['email' => 'member@example.com']);
$response = $this->postJson('/api/member/login', [
'email' => 'member@example.com',
'password' => 'password123',
]);
$response->assertStatus(200)
->assertJsonPath('status', true)
->assertJsonPath('data.token_type', 'Bearer')
->assertJsonStructure(['data' => ['token', 'token_type', 'user']]);
}
public function test_member_login_wrong_password_returns_401(): void
{
$this->createMember(['email' => 'member@example.com']);
$response = $this->postJson('/api/member/login', [
'email' => 'member@example.com',
'password' => 'wrong-password',
]);
$response->assertStatus(401)
->assertJsonPath('status', false);
}
public function test_member_login_inactive_account_returns_403(): void
{
$this->createMember(['email' => 'inactive@example.com', 'is_active' => false]);
$response = $this->postJson('/api/member/login', [
'email' => 'inactive@example.com',
'password' => 'password123',
]);
$response->assertStatus(403);
}
// 下面這類「跨角色」測試在防的是:provider/admin 帳號明明存在於 users 表,
// 卻拿著正確密碼跑去敲 member 的登入端點——查詢必須有 role 過濾,
// 否則系統可能誤判成「帳號密碼正確」而放行,讓使用者用錯誤的角色身分登入。
// 回傳 401(而非更精確的「角色不對」訊息)是刻意的:跟「帳號不存在」用同樣的錯誤,
// 才不會讓外部攻擊者藉由錯誤訊息差異去探測「這個 email 是不是某個角色的帳號」。
public function test_provider_account_cannot_use_member_login(): void
{
$this->createProvider(['email' => 'cross@example.com']);
$response = $this->postJson('/api/member/login', [
'email' => 'cross@example.com',
'password' => 'password123',
]);
// 查詢以 role 過濾,跨角色帳號視同不存在,回傳與「帳號不存在」相同的 401
$response->assertStatus(401)
->assertJsonPath('status', false);
}
public function test_member_logout_revokes_token(): void
{
$user = $this->createMember();
$token = $user->createToken('auth_token')->plainTextToken;
$tokenId = explode('|', $token, 2)[0];
$this->withToken($token)->postJson('/api/member/logout')
->assertStatus(200);
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]);
// RequestGuard 會在同一個測試內快取已解析的 user,須先 forgetGuards() 才能讓
// 下一個請求重新解析(此時 token 已被刪除,應得 401)
Auth::forgetGuards();
$this->withToken($token)->getJson('/api/member/profile')
->assertStatus(401);
}
// ── provider 註冊 ────────────────────────────────────────
public function test_provider_register_success(): void
{
$response = $this->postJson('/api/provider/register', [
'name' => 'Test Provider',
'email' => 'newprovider@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201)
->assertJsonPath('status', true)
->assertJsonStructure(['data' => ['user']]);
$this->assertDatabaseHas('users', [
'email' => 'newprovider@example.com',
'role' => 'provider',
]);
}
public function test_provider_register_duplicate_email_returns_422(): void
{
$this->createProvider(['email' => 'dup-provider@example.com']);
$response = $this->postJson('/api/provider/register', [
'name' => 'Another Provider',
'email' => 'dup-provider@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(422);
}
// ── provider 登入/登出 ──────────────────────────────────
public function test_provider_login_success(): void
{
$this->createProvider(['email' => 'provider@example.com']);
$response = $this->postJson('/api/provider/login', [
'email' => 'provider@example.com',
'password' => 'password123',
]);
$response->assertStatus(200)
->assertJsonPath('status', true)
->assertJsonPath('data.token_type', 'Bearer')
->assertJsonStructure(['data' => ['token', 'token_type', 'user']]);
}
public function test_provider_login_inactive_account_returns_403(): void
{
$this->createProvider(['email' => 'inactive-provider@example.com', 'is_active' => false]);
$response = $this->postJson('/api/provider/login', [
'email' => 'inactive-provider@example.com',
'password' => 'password123',
]);
$response->assertStatus(403);
}
public function test_member_account_cannot_use_provider_login(): void
{
$this->createMember(['email' => 'cross2@example.com']);
$response = $this->postJson('/api/provider/login', [
'email' => 'cross2@example.com',
'password' => 'password123',
]);
$response->assertStatus(401)
->assertJsonPath('status', false);
}
public function test_provider_logout_revokes_token(): void
{
$user = $this->createProvider();
$token = $user->createToken('auth_token')->plainTextToken;
$tokenId = explode('|', $token, 2)[0];
$this->withToken($token)->postJson('/api/provider/logout')
->assertStatus(200);
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]);
Auth::forgetGuards();
$this->withToken($token)->getJson('/api/provider/profile')
->assertStatus(401);
}
// ── admin 註冊 ───────────────────────────────────────────
public function test_admin_register_success(): void
{
$response = $this->postJson('/api/admin/register', [
'name' => 'Test Admin',
'email' => 'newadmin@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201)
->assertJsonPath('status', true)
->assertJsonStructure(['data' => ['user']]);
$this->assertDatabaseHas('users', [
'email' => 'newadmin@example.com',
'role' => 'admin',
]);
}
public function test_admin_register_duplicate_email_returns_422(): void
{
$this->createAdmin(['email' => 'dup-admin@example.com']);
$response = $this->postJson('/api/admin/register', [
'name' => 'Another Admin',
'email' => 'dup-admin@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(422);
}
// ── admin 登入/登出 ─────────────────────────────────────
public function test_admin_login_success(): void
{
$this->createAdmin(['email' => 'admin@example.com']);
$response = $this->postJson('/api/admin/login', [
'email' => 'admin@example.com',
'password' => 'password123',
]);
$response->assertStatus(200)
->assertJsonPath('status', true)
->assertJsonPath('data.token_type', 'Bearer')
->assertJsonStructure(['data' => ['token', 'token_type', 'user']]);
}
public function test_member_cannot_use_admin_login(): void
{
$this->createMember(['email' => 'cross3@example.com']);
$response = $this->postJson('/api/admin/login', [
'email' => 'cross3@example.com',
'password' => 'password123',
]);
$response->assertStatus(401)
->assertJsonPath('status', false);
}
public function test_provider_cannot_use_admin_login(): void
{
$this->createProvider(['email' => 'cross4@example.com']);
$response = $this->postJson('/api/admin/login', [
'email' => 'cross4@example.com',
'password' => 'password123',
]);
$response->assertStatus(401)
->assertJsonPath('status', false);
}
public function test_admin_logout_revokes_token(): void
{
$user = $this->createAdmin();
$token = $user->createToken('auth_token')->plainTextToken;
$tokenId = explode('|', $token, 2)[0];
$this->withToken($token)->postJson('/api/admin/logout')
->assertStatus(200);
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]);
Auth::forgetGuards();
$this->withToken($token)->getJson('/api/admin/profile')
->assertStatus(401);
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Tests\TestCase;
class AuthOAuthTest extends TestCase
{
use RefreshDatabase;
// ── helpers ─────────────────────────────────────────────
/**
* 組一個假的 Google 使用者物件,餵給 mock 過的 Socialite。
* 測試不應該真的打 Google API(會很慢、不穩定、還要真帳密),
* 所以用 Mockery Socialite 換成假的,並回傳這個假資料。
*/
private function fakeGoogleUser(string $id, string $email, string $name = 'Google Test User'): SocialiteUser
{
$user = new SocialiteUser();
$user->id = $id;
$user->name = $name;
$user->email = $email;
$user->token = 'fake-access-token-' . $id;
return $user;
}
// ── state 缺失 / 不符 ────────────────────────────────────
//
// 「state」是 OAuth 流程裡用來防 CSRF 的一次性亂碼:後端發起登入時把它存進
// sessionGoogle callback 回來時要原樣帶回,兩邊對得上才繼續,對不上就視為
// 攻擊/偽造請求,直接擋下、不跟 Google 要使用者資料。
// 下面兩個測試就是在驗證「對不上的時候真的有擋下」這個安全底線。
public function test_oauth_callback_without_state_redirects_to_error(): void
{
// shouldReceive('driver')->never():斷言 Socialite::driver(...) 整個流程
// 都「不應該被呼叫」。這是防回歸的關鍵——萬一未來有人改壞了驗證順序,
// 導致系統在 state 比對「之前」就先去問 Google 拿使用者資料,這裡就會爆紅。
Socialite::shouldReceive('driver')->never();
$response = $this->get('/api/auth/google/callback');
$response->assertRedirectContains('error=oauth_failed');
}
public function test_oauth_callback_with_wrong_state_redirects_to_error(): void
{
Socialite::shouldReceive('driver')->never();
$response = $this->withSession(['oauth_state' => 'correct-state'])
->get('/api/auth/google/callback?state=wrong-state');
$response->assertRedirectContains('error=oauth_failed');
}
// ── state 正確 ───────────────────────────────────────────
public function test_oauth_callback_with_correct_state_completes_login(): void
{
// shouldReceive('driver->user'):模擬「Socialite::driver('google')->user()」這條
// 鏈式呼叫,讓它回傳上面準備好的假 Google 使用者,藉此驗證「state 對得上時,
// 系統會繼續走完登入流程並帶著 token 導回前端」。
Socialite::shouldReceive('driver->user')
->andReturn($this->fakeGoogleUser('google-state-ok', 'state-ok@example.com'));
$response = $this->withSession(['oauth_state' => 'matching-state'])
->get('/api/auth/google/callback?state=matching-state');
$response->assertRedirectContains('#token=');
}
public function test_oauth_state_is_consumed_after_successful_callback(): void
{
// 驗證「state 是一次性的」:用過一次就該從 session 移除,
// 避免同一組 state 被重放(replay attack)拿來再次完成登入。
Socialite::shouldReceive('driver->user')
->andReturn($this->fakeGoogleUser('google-one-time', 'one-time@example.com'));
// 第一次:state 正確,完成登入;session 中的 oauth_state 在驗證時被 pull() 消耗
$this->withSession(['oauth_state' => 'one-time-state'])
->get('/api/auth/google/callback?state=one-time-state')
->assertRedirectContains('#token=');
// 第二次:再帶相同 state 呼叫,但 session 已無 oauth_state,視同 state 缺失
$response = $this->get('/api/auth/google/callback?state=one-time-state');
$response->assertRedirectContains('error=oauth_failed');
}
}
+5 -5
View File
@@ -17,11 +17,11 @@ class AuthRateLimitTest extends TestCase
Cache::flush();
}
// ── member (throttle:5,1) ───────────────────────────────
// ── member (throttle:10,1) ───────────────────────────────
public function test_member_login_within_limit_is_not_throttled(): void
{
for ($i = 0; $i < 5; $i++) {
for ($i = 0; $i < 10; $i++) {
$response = $this->postJson('/api/member/login', [
'email' => 'notexist@example.com',
'password' => 'wrong',
@@ -32,7 +32,7 @@ class AuthRateLimitTest extends TestCase
public function test_member_login_exceeds_limit_returns_429(): void
{
for ($i = 0; $i < 5; $i++) {
for ($i = 0; $i < 10; $i++) {
$this->postJson('/api/member/login', [
'email' => 'notexist@example.com',
'password' => 'wrong',
@@ -48,11 +48,11 @@ class AuthRateLimitTest extends TestCase
$this->assertTrue($response->headers->has('Retry-After'));
}
// ── provider (throttle:5,1) ─────────────────────────────
// ── provider (throttle:10,1) ─────────────────────────────
public function test_provider_login_exceeds_limit_returns_429(): void
{
for ($i = 0; $i < 5; $i++) {
for ($i = 0; $i < 10; $i++) {
$this->postJson('/api/provider/login', [
'email' => 'notexist@example.com',
'password' => 'wrong',