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>
This commit is contained in:
2026-06-08 04:01:33 +08:00
parent 0dabc4ebb5
commit 6f446f601f
9 changed files with 1028 additions and 0 deletions
+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);
}
}