Skip to content

Conversation

@julian-richter
Copy link
Member

No description provided.

@linear
Copy link

linear bot commented Nov 29, 2025

@coderabbitai
Copy link

coderabbitai bot commented Nov 29, 2025

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features
    • Complete API authentication system with login, registration, and password reset functionality
    • Two-factor authentication for enhanced account security
    • Account management features including deactivation, reactivation, and deletion
    • Session and device management for API access
    • Profile and password update endpoints
    • Email verification for new accounts
    • API rate limiting with configurable request thresholds
    • OpenAPI specification documentation for API endpoints

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

Adds a complete versioned API surface: registration/login/logout/sessions, password reset, email verification, two‑factor flows, account deactivate/reactivate/delete; new FormRequests, ApiThrottle middleware/config, ApiResponse helper, OpenAPI generator + spec, UserDeletionService, User model changes, migrations and indexes, many controllers/resources, extensive tests, and CI/frontend pnpm changes.

Changes

Cohort / File(s) Summary
API Controllers
app/Http/Controllers/Api/Auth/*
(LoginController.php, RegisterController.php, LogoutController.php, PasswordController.php, EmailVerificationController.php, TwoFactorController.php, AccountController.php, ProfileController.php, UserController.php, SessionController.php)
New controllers implementing token-per-device login/register, logout (single/all), password flows, email verification send/verify, full 2FA lifecycle (enable/confirm/recovery/regenerate/disable), account deactivate/reactivate/delete, profile update, session listing/revoke. Use FormRequests, ApiResponse, UserResource and services.
Form Requests
app/Http/Requests/Api/Auth/*
(LoginRequest.php, RegisterRequest.php, ForgotPasswordRequest.php, ResetPasswordRequest.php, UpdatePasswordRequest.php, UpdateProfileRequest.php, VerifyEmailRequest.php, DeactivateAccountRequest.php, DeleteAccountRequest.php, EnableTwoFactorRequest.php, ConfirmTwoFactorRequest.php, DisableTwoFactorRequest.php, RegenerateRecoveryCodesRequest.php)
New FormRequest classes centralizing validation/authorization and custom messages for all API auth endpoints.
Middleware & Routing
app/Http/Middleware/ApiThrottle.php, routes/api.php, bootstrap/app.php
New ApiThrottle middleware extending ThrottleRequests with route-aware limits and ApiResponse 429 payload; routes reorganized under /api/v1 with apiThrottle applied; middleware alias registered.
Models / Notifications / Helpers / Resources
app/Models/User.php, app/Notifications/VerifyEmailNotification.php, app/Helpers/ApiResponse.php, app/OpenApi/Operation.php, app/Support/TimeFormatter.php, app/Http/Resources/UserResource.php
User model: SoftDeletes, deactivation fields, encrypted casts for 2FA data, custom sendEmailVerificationNotification. New VerifyEmailNotification (signed URL), ApiResponse helper for standardized JSON responses, Operation attribute for OpenAPI metadata, TimeFormatter utility, and UserResource.
Services
app/Services/UserDeletionService.php
New service for GDPR-aware hard delete and soft deactivate; transactional user cleanup, token removal, optional notifications cleanup, and logging.
OpenAPI & Generator
app/Console/Commands/GenerateOpenApiSpec.php, storage/openapi.yaml
New Artisan command to generate OpenAPI 3.0.3 YAML from routes/controllers/FormRequests; generated spec added to storage.
Migrations
database/migrations/*
(2025_11_29_202535_add_account_management_to_users_table.php, 2025_11_29_211024_add_cascade_delete_to_sessions_table.php, 2025_11_30_174945_add_indexes_to_user_account_management_columns.php)
Add softDeletes and deactivation columns to users, cascade delete on sessions.user_id, and indexes on is_deactivated/deactivated_at.
Tests
tests/Feature/ApiTest.php, plus updates across tests/Feature/* and tests/Unit/*
Large new Pest API test suite covering full flows; many existing tests updated with explicit : void return types, some import adjustments to use the new VerifyEmailNotification, and added unit tests for TimeFormatter/UserResource.
Config / Env / CI / Tooling
config/api.php, config/postman.php, config/sanctum.php, config/pulse.php, .env.example, composer.json, phpunit.xml, rector.php, .github/workflows/*
New API throttle config; Postman defaults updated; safer env casting in configs; added dev dependency driftingly/rector-laravel; APP_KEY added to phpunit.xml; Rector config; CI lint/tests workflows switched to pnpm and workflow triggers adjusted.
Routes / Minor refactors
routes/*.php, app/Actions/Fortify/CreateNewUser.php, app/Http/Controllers/Settings/ProfileController.php
Route closures converted to arrow functions/explicit return types; CreateNewUser removed runtime validation (moved to FormRequests); ProfileController now injects UserDeletionService and wraps deletion in try/catch with logging.
Postman Collection
storage/postman/api_collection
Reworked collection into versioned v1 set with bearer auth variable and many endpoint entries for the new API surface.
CI / Lint Workflow
.github/workflows/tests.yml, .github/workflows/lint.yml
CI switched Node steps from npm to pnpm, added pnpm cache/setup steps, and adjusted workflow triggers from main→master branches.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant API as RegisterController
    participant Req as RegisterRequest
    participant DB as Database
    participant Sanctum as Sanctum
    participant Mailer as Notification Queue

    Client->>API: POST /api/v1/register (name,email,password)
    API->>Req: validate()
    Req-->>API: validated data
    API->>DB: User::create(...)
    DB-->>API: User
    API->>Sanctum: createToken(api-token-{device})
    Sanctum-->>API: token
    API->>Mailer: dispatch VerifyEmailNotification
    Mailer-->>API: queued
    API-->>Client: 201 {user, token, token_type}
Loading
sequenceDiagram
    actor Client
    participant API as TwoFactorController
    participant Google2FA as Google2FA Lib
    participant Crypto as Laravel Encryption
    participant DB as Database

    Client->>API: POST /api/v1/user/two-factor-authentication
    API->>Google2FA: generateSecret()
    Google2FA-->>API: secret
    API->>Crypto: encrypt(secret)
    Crypto-->>API: encrypted_secret
    API->>DB: save encrypted secret on user
    DB-->>API: stored
    API-->>Client: 200 {secret, qr_code_url}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Areas needing focused review:

  • app/Console/Commands/GenerateOpenApiSpec.php — reflection, FormRequest rule parsing, and schema generation edge cases.
  • app/Http/Controllers/Api/Auth/TwoFactorController.php — encryption/decryption, recovery code generation/storage, Google2FA verification edge cases.
  • app/Http/Middleware/ApiThrottle.php + config/api.php — ensure mapping to Laravel RateLimiter semantics and Retry-After header correctness.
  • app/Services/UserDeletionService.php — transactional correctness, token cleanup, forceDelete implications and logging exposure.
  • database migrations — schema changes ordering, cascade FK change and index rollbacks.
  • tests/Feature/ApiTest.php — stability of signed URLs, queue/notification timing, and token lifetimes.

Suggested labels

docs, security

Poem

🔐 Tokens spawn, secrets hide in vaults of code,
Forms validate the chaos, routes march in a row.
Migrations dig the graves, but tests light up the night —
CI swapped to pnpm; hope your CI stays polite.
Review with coffee, and for God’s sake, check the logs.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.05% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description check ❓ Inconclusive No description was provided by the author. While the PR objectives clarify the scope, the complete absence of a description makes this inconclusive. Add a brief description explaining what this PR implements and why, even just 2-3 sentences summarizing the API routes, OpenAPI generation, and supporting infrastructure.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly references the main objective: implementing missing API routes and OpenAPI specification generation (das-22 Jira ticket). It directly summarizes the primary change.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch DAS-22

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • DAS-22: Authentication required, not authenticated - You need to authenticate to access this operation.

Comment @coderabbitai help to get the list of available commands and usage tips.

@julian-richter
Copy link
Member Author

julian-richter commented Nov 29, 2025

⚠️ **This documentation is outdated and has been superseded by a newer version**
⚠️ **This documentation is outdated and has been superseded by a newer version**
⚠️ **This documentation is outdated and has been superseded by a newer version**## 📚 Automated Documentation Summary

das-22-implement-missing-api-routes-author-openapi-specification

This PR adds missing API routes, a comprehensive OpenAPI spec generator, standardized API responses, enhanced account management, two-factor authentication, and request validation. It centralizes response formatting, enforces security via FormRequests, and implements GDPR-compliant user deletion.


CreateNewUser Action

Removes inline validation in favor of FormRequest classes.
Validation now occurs in RegisterRequest, ensuring consistency and reusability.

public function create(array $input): User
{
    return User::create([
        'name' => $input['name'],
        'email' => $input['email'],
        'password' => $input['password'],
    ]);
}

Implications

  • Cleaner action class.
  • Centralized validation rules.
  • Easier to maintain and test.

GenerateOpenApiSpec Command

Generates an OpenAPI 3.0.3 YAML spec for all api.v1.* routes. It:

  • Discovers API routes by name, middleware, or URI prefix.
  • Reflects controller docblocks and attributes.
  • Builds paths, operations, request bodies, responses, and security schemes.
  • Outputs to storage/openapi.yaml or custom path.
php artisan openapi:generate --output=docs/openapi.yaml

Key Features

  • Automatic schema extraction from FormRequests.
  • Customizable via attributes (App\OpenApi\Operation).
  • Includes rate-limit responses and bearerAuth.

ApiResponse Helper

Provides uniform JSON responses:

Method Status Description
success() 200 Standard success payload
error() 400+ General errors with optional code
validationError() 422 Validation failures
unauthorized() 401 Authentication required
forbidden() 403 Permission denied
notFound() 404 Resource missing
rateLimited() 429 Throttling response with headers
return ApiResponse::success($data, 'Fetched successfully');

Implications

  • Consistent API format.
  • Automatic HTTP codes.
  • Enhanced error codes for clients.

Middleware: ApiThrottle

Extends Laravel’s ThrottleRequests to apply custom limits:

  • Auth routes: 5 attempts/min (configurable).
  • Password reset: 3 attempts/15 min.
  • Default API: 100 attempts/min.
  • Returns standardized 429 JSON via ApiResponse.
// In Kernel.php
'apiThrottle' => \App\Http\Middleware\ApiThrottle::class,

Model & Notification Updates

  • User model adds SoftDeletes, deactivation fields, encrypted 2FA secrets, and overrides sendEmailVerificationNotification().
  • VerifyEmailNotification sends a signed URL for email verification via API.
$user->notify(new VerifyEmailNotification);

API Controllers & Endpoints

All endpoints use ApiResponse and FormRequests to enforce validation and security. The baseUrl for examples is https://api.example.com.


Register a New User

Registers and returns a Sanctum token.

{
  "title": "Register User",
  "description": "Create a new user account and return access token",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/register",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"John Doe\",\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\",\n  \"password_confirmation\": \"password123\"\n}",
  "responses": {
    "201": {
      "description": "User registered successfully",
      "body": "{\n  \"success\": true,\n  \"message\": \"User registered successfully\",\n  \"data\": {\n    \"user\": {\"id\":1,\"name\":\"John Doe\",\"email\":\"[email protected]\"},\n    \"token\":\"<token>\",\n    \"token_type\":\"Bearer\"\n  }\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed\",\n  \"errors\": {\"email\":[\"The email has already been taken.\"]}\n}"
    },
    "500": {
      "description": "Registration error",
      "body": "{\n  \"success\": false,\n  \"message\": \"Registration failed\",\n  \"error_code\": \"REGISTRATION_ERROR\"\n}"
    }
  }
}

Login

Authenticates user and returns a token.

{
  "title": "User Login",
  "description": "Authenticate with email and password",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/login",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\",\n  \"device\": \"web\"\n}",
  "responses": {
    "200": {
      "description": "Login successful",
      "body": "{\n  \"success\": true,\n  \"message\": \"Login successful\",\n  \"data\": {\"user\": {\"id\":1,\"name\":\"John Doe\",\"email\":\"[email protected]\"},\"token\":\"<token>\",\"token_type\":\"Bearer\"}\n}"
    },
    "422": {
      "description": "Invalid credentials",
      "body": "{\n  \"errors\": {\"email\":[\"The provided credentials are incorrect.\"]}\n}"
    }
  }
}

Logout (Current Device)

Revokes current token.

{
  "title": "Logout Current Device",
  "description": "Revoke the current API token",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Logged out successfully",
      "body": "{\n  \"success\": true,\n  \"message\": \"Successfully logged out from this device.\",\n  \"data\": null\n}"
    },
    "400": {
      "description": "Requires token auth",
      "body": "{\n  \"success\": false,\n  \"message\": \"This endpoint requires token-based authentication. Use session logout instead.\",\n  \"error_code\": \"TOKEN_AUTH_REQUIRED\"\n}"
    }
  }
}

Logout All Devices

Revokes all tokens.

{
  "title": "Logout All Devices",
  "description": "Revoke all API tokens for the user",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout-all",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "All sessions terminated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Successfully logged out from all devices.\",\n  \"data\": null\n}"
    }
  }
}

Get Authenticated User

Returns current user info.

{
  "title": "Get Current User",
  "description": "Retrieve authenticated user information",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "User data",
      "body": "{\n  \"success\": true,\n  \"data\": {\"user\": {\"id\":1,\"name\":\"John Doe\",\"email\":\"[email protected]\"}}\n}"
    }
  }
}

Update Profile

Edits name/email; sends verification if email changes.

{
  "title": "Update Profile",
  "description": "Modify user name or email",
  "method": "PATCH",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/profile/update",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"Jane Doe\",\n  \"email\": \"[email protected]\",\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Profile updated",
      "body": "{\n  \"data\": {\"user\": {\"id\":1,\"name\":\"Jane Doe\",\"email\":\"[email protected]\",\"email_verified_at\":null}},\n  \"message\": \"Profile updated successfully.\"\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"errors\": {\"email\":[\"This email address is already in use.\"]}\n}"
    }
  }
}

Update Password

Changes password and revokes other tokens.

{
  "title": "Update Password",
  "description": "Change user password",
  "method": "PATCH",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/password/update",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"oldpass123\",\n  \"password\": \"newpass123\",\n  \"password_confirmation\": \"newpass123\"\n}",
  "responses": {
    "200": {
      "description": "Password updated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Password updated successfully.\" \n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"errors\": {\"current_password\":[\"The current password is incorrect.\"]}\n}"
    }
  }
}

Forgot Password

Sends reset link to email.

{
  "title": "Forgot Password",
  "description": "Request password reset link",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/forgot-password",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\"\n}",
  "responses": {
    "200": {
      "description": "Reset link sent",
      "body": "{\n  \"success\": true,\n  \"message\": \"Password reset link sent to your email address.\" \n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"errors\": {\"email\":[\"Please provide a valid email address.\"]}\n}"
    }
  }
}

Reset Password

Resets password using token.

{
  "title": "Reset Password",
  "description": "Reset password with token",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/reset-password",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"token\": \"reset-token\",\n  \"password\": \"newpass123\",\n  \"password_confirmation\": \"newpass123\"\n}",
  "responses": {
    "200": {
      "description": "Password reset",
      "body": "{\n  \"success\": true,\n  \"message\": \"Password has been reset successfully.\" \n}"
    },
    "422": {
      "description": "Invalid token or validation error",
      "body": "{\n  \"errors\": {\"email\":[\"This password reset token is invalid.\"]}\n}"
    }
  }
}

Deactivate Account

Marks account deactivated and revokes tokens.

{
  "title": "Deactivate Account",
  "description": "Temporarily disable user account",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/deactivate",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\",\n  \"reason\": \"No longer needed\"\n}",
  "responses": {
    "200": {
      "description": "Account deactivated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been deactivated successfully. All sessions have been terminated.\" \n}"
    },
    "400": {
      "description": "Already deactivated",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"ACCOUNT_ALREADY_DEACTIVATED\",\n  \"message\": \"Account is already deactivated.\" \n}"
    },
    "422": {
      "description": "Invalid password",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"INVALID_CURRENT_PASSWORD\",\n  \"message\": \"Current password is incorrect.\" \n}"
    }
  }
}

Reactivate Account

Re-enables a deactivated account.

{
  "title": "Reactivate Account",
  "description": "Restore a deactivated account",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/reactivate",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Account reactivated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been reactivated successfully.\" \n}"
    },
    "400": {
      "description": "Already active",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"ACCOUNT_ALREADY_ACTIVE\",\n  \"message\": \"Account is already active.\" \n}"
    }
  }
}

Delete Account

Permanently deletes user via GDPR-compliant service.

{
  "title": "Delete Account",
  "description": "Permanently remove user and related data",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/account/delete",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Account deleted",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been deleted successfully.\" \n}"
    },
    "422": {
      "description": "Invalid password",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"INVALID_CURRENT_PASSWORD\",\n  \"message\": \"Current password is incorrect.\" \n}"
    }
  }
}

Send Email Verification

Sends a signed verification link.

{
  "title": "Send Verification Email",
  "description": "Email user a verification link",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verification-notification",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Link sent",
      "body": "{\n  \"success\": true,\n  \"message\": \"Email verification link sent successfully.\" \n}"
    },
    "400": {
      "description": "Already verified",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"EMAIL_ALREADY_VERIFIED\",\n  \"message\": \"Email is already verified.\" \n}"
    },
    "500": {
      "description": "Send failed",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"EMAIL_SEND_FAILED\",\n  \"message\": \"Failed to send verification email. Please try again later.\" \n}"
    }
  }
}

Verify Email

Confirms email via signed link.

{
  "title": "Verify Email",
  "description": "Validate email verification link",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verify",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"id\":1,\n  \"hash\":\"<hash>\",\n  \"signature\":\"<signature>\"\n}",
  "responses": {
    "200": {
      "description": "Email verified",
      "body": "{\n  \"success\": true,\n  \"message\": \"Email verified successfully.\",\n  \"data\": {\"user\": {\"id\":1,\"name\":\"John Doe\",\"email\":\"[email protected]\",\"email_verified_at\":\"2025-01-01T00:00:00Z\"}}\n}"
    },
    "400": {
      "description": "Invalid or expired",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"INVALID_VERIFICATION_LINK\",\n  \"message\": \"Invalid verification link.\" \n}"
    }
  }
}

Enable Two-Factor Authentication

Initiates 2FA setup.

{
  "title": "Enable Two-Factor",
  "description": "Generate 2FA secret and QR code",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/enable",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Setup initiated",
      "body": "{\n  \"data\": {\"secret\":\"<secret>\",\"qr_code_url\":\"<url>\",\"next_step\":\"Scan...\"},\n  \"message\": \"Two-factor authentication setup initiated.\" \n}"
    },
    "400": {
      "description": "Already enabled",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"TWO_FACTOR_ALREADY_ENABLED\",\n  \"message\": \"Two-factor authentication is already enabled.\" \n}"
    }
  }
}

Confirm Two-Factor Authentication

Verifies 2FA code and generates recovery codes.

{
  "title": "Confirm Two-Factor",
  "description": "Validate 2FA code and enable it",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/confirm",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"code\":\"123456\",\n  \"password\":\"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA enabled",
      "body": "{\n  \"data\": {\"recovery_codes\":[\"ABCDEF...\",...],\"warning\":\"Save these codes.\"},\n  \"message\": \"Two-factor authentication has been enabled successfully.\" \n}"
    },
    "422": {
      "description": "Invalid code or setup not initiated",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"INVALID_TWO_FACTOR_CODE\",\n  \"message\": \"Invalid two-factor authentication code.\" \n}"
    }
  }
}

Get Recovery Codes

Retrieves active recovery codes.

{
  "title": "Get Recovery Codes",
  "description": "Fetch saved 2FA recovery codes",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Codes retrieved",
      "body": "{\n  \"data\": {\"recovery_codes\":[\"ABCDEF...\",...],\"warning\":\"Make sure to save...\"},\n  \"message\": \"Recovery codes retrieved successfully.\" \n}"
    }
  }
}

Regenerate Recovery Codes

Invalidates old codes and returns new ones.

{
  "title": "Regenerate Recovery Codes",
  "description": "Create new 2FA recovery codes",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes/regenerate",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "New codes",
      "body": "{\n  \"data\": {\"recovery_codes\":[\"XYZ123...\",...],\"warning\":\"Previous codes invalidated.\"},\n  \"message\": \"Recovery codes regenerated successfully.\" \n}"
    }
  }
}

Disable Two-Factor Authentication

Turns off 2FA.

{
  "title": "Disable Two-Factor",
  "description": "Remove 2FA secrets and codes",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/disable",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "2FA disabled",
      "body": "{\n  \"success\": true,\n  \"message\": \"Two-factor authentication has been disabled successfully.\" \n}"
    }
  }
}

List Sessions

Lists active API tokens.

{
  "title": "List Sessions",
  "description": "Get active API sessions",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Session list",
      "body": "{\n  \"data\": {\"sessions\":[{...}],\"total\":3},\n  \"message\": \"Operation successful\" \n}"
    }
  }
}

Revoke Session

Deletes a specific token (not current).

{
  "title": "Revoke Session",
  "description": "Remove a specific API session token",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions/123",
  "headers": [
    {"key":"Authorization","value":"Bearer <token>","required":true}
  ],
  "pathParams": [
    {"key":"sessionId","value":"Session token ID","required":true}
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Session revoked",
      "body": "{\n  \"success\": true,\n  \"message\": \"Session revoked successfully.\" \n}"
    },
    "404": {
      "description": "Not found",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"SESSION_NOT_FOUND\",\n  \"message\": \"Session not found.\" \n}"
    },
    "400": {
      "description": "Cannot revoke current session",
      "body": "{\n  \"success\": false,\n  \"error_code\": \"CANNOT_REVOKE_CURRENT_SESSION\",\n  \"message\": \"Cannot revoke the current session.\" \n}"
    }
  }
}

Settings Profile Deletion (Web)

The web Settings\ProfileController now uses UserDeletionService for GDPR-compliance:

$deletionService->deleteUser($user, 'User requested account deletion via web interface');

Implications

  • Atomic deletion via transactions.
  • Audit logs and cascade cleanup.
  • Service reuse across web and API.

HandleInertiaRequests Update

Casts Inertia-shared quote data to string for type safety:

- 'quote' => ['message' => trim($message), 'author' => trim($author)],
+ 'quote' => ['message' => trim((string) $message), 'author' => trim((string) $author)],

Request Validation Classes

Each API endpoint now has a dedicated FormRequest:

  • Auth: LoginRequest, RegisterRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdatePasswordRequest, ConfirmTwoFactorRequest, EnableTwoFactorRequest, DeactivateAccountRequest, DeleteAccountRequest, VerifyEmailRequest, UpdateProfileRequest.
  • Enforces authorize() and custom messages.
  • Secures routes with auth()->check() defense in depth.

This PR delivers a robust API foundation with standardized responses, full validation, security best practices, and automated OpenAPI documentation generation.


This documentation was automatically generated by DocuWriter.ai 🚀

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 63

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Http/Middleware/HandleInertiaRequests.php (1)

39-49: String casts are fine, but this whole quote pipeline is trying way too hard

The (string) casts before trim() are a reasonable fix to keep PHP 8.1+ from whining when $author comes back as null. No complaints there.

The rest of this, though:

[$message, $author] = str(Inspiring::quotes()->random())->explode('-');

is classic “I saw Stringable in the docs once” energy. You’re spinning up a Stringable, turning it into a Collection, then destructuring it, just to split on a dash. Also, if the quote doesn’t contain '-', $author becomes null and silently turns into '' via the cast, which is a bit magic.

If you ever touch this again, consider something closer to:

[$message, $author] = explode('-', Inspiring::quotes()->random(), 2);

$quote = [
    'message' => trim((string) $message),
    // keep `null` when there is no author, instead of hiding it as ''
    'author'  => $author !== null ? trim((string) $author) : null,
];

Same behavior for normal quotes, clearer intent, less Laravel ceremony for what’s basically explode(). But as-is, the new casts themselves are fine to ship.

app/Actions/Fortify/CreateNewUser.php (1)

17-24: I'll help you verify the concerns raised in this review comment about password hashing and validation. Let me investigate the User model's password handling and how the CreateNewUser action is called.
<function_calls>


#!/bin/bash

First, let's find the User model and check for password hashing mechanism

echo "=== Finding User model ==="
fd -t f "User.php" app/ | head -20

echo ""
echo "=== Checking User model for password mutator/cast ==="
if [ -f "app/Models/User.php" ]; then
wc -l app/Models/User.php
fi


</function_calls>

<function_calls>


#!/bin/bash

Read the User model to check for password handling

cat -n app/Models/User.php


</function_calls>

<function_calls>


#!/bin/bash

Search for password mutator specifically and casts array

echo "=== Searching for password mutator in User model ==="
rg -n "setPasswordAttribute|'password'|makeHidden" app/Models/User.php -A3 -B1

echo ""
echo "=== Searching for 'password' references in casts or attributes ==="
rg -n "protected.$casts|protected.$hidden" app/Models/User.php -A5


</function_calls>

<function_calls>


#!/bin/bash

Find all usages of CreateNewUser action

echo "=== Finding CreateNewUser usage ==="
rg -n "CreateNewUser|create(array" app/Actions/Fortify/ -C5

echo ""
echo "=== Searching broader for CreateNewUser ==="
rg -rn "new CreateNewUser|CreateNewUser" --type=php -C3


</function_calls>

<function_calls>


#!/bin/bash

Check if there's a Fortify provider or configuration that handles validation

echo "=== Looking for Fortify related files ==="
find . -path ./node_modules -prune -o -name "Fortify" -type f -print | head -20

echo ""
echo "=== Checking CreateNewUser file itself ==="
head -50 app/Actions/Fortify/CreateNewUser.php


</function_calls>

<function_calls>


#!/bin/bash

Check config/fortify.php for registration validation

echo "=== Checking Fortify configuration ==="
if [ -f "config/fortify.php" ]; then
cat -n config/fortify.php | head -100
fi


</function_calls>

<function_calls>


#!/bin/bash

Look for Request validation classes for registration

echo "=== Finding Request classes ==="
fd -t f -e php "Request" app/ | grep -i register

echo ""
echo "=== Checking for validation rules in registration ==="
rg -n "rules()|authorize()" --type=php | head -30


</function_calls>

<function_calls>


#!/bin/bash

Check if User model uses Hash facade or mutator for password

echo "=== Searching for Hash usage in User model ==="
rg -n "Hash|mutate|password" app/Models/User.php

echo ""
echo "=== Checking Laravel's authentication scaffolding patterns ==="
echo "Looking for trait usage that might handle password hashing:"
rg -n "use.*Authenticatable|implements.*UserContract" app/Models/User.php


</function_calls>

Now let me check web documentation about Laravel's default password handling:
<function_calls>

Laravel User model password hashing setPasswordAttribute mutator

</function_calls>

<function_calls>

Laravel Fortify CreateNewUser password validation registration

</function_calls>

<function_calls>


#!/bin/bash

Let's check the broader context - looking at how registration flows through Fortify

echo "=== Looking for registration controller or action ==="
fd -t f ".Register..php" --type f | head -20

echo ""
echo "=== Checking app/Http directory ==="
find app/Http -name "*.php" -type f 2>/dev/null | head -20


</function_calls>

<function_calls>


#!/bin/bash

Look for where CreateNewUser gets instantiated and how it's called

echo "=== Complete search for CreateNewUser instantiation ==="
rg -n "CreateNewUser" --type=php -B5 -A5

echo ""
echo "=== Check how the action receives its input ==="
rg -n "->create(" --type=php -B3 -A3 | head -50


</function_calls>

<function_calls>


#!/bin/bash

Look at the entire CreateNewUser file

echo "=== Complete CreateNewUser.php file ==="
cat -n app/Actions/Fortify/CreateNewUser.php

echo ""
echo "=== Check complete User.php file ==="
wc -l app/Models/User.php


</function_calls>

Let me get more specific information about the User model:
<function_calls>


#!/bin/bash

Full User model content

if [ -f "app/Models/User.php" ]; then
cat app/Models/User.php
fi


</function_calls>

<function_calls>


#!/bin/bash

Check for a RegistrationRequest or similar validation class

echo "=== Looking for Requests directory ==="
find app -type d -name "Requests" 2>/dev/null

echo ""
echo "=== Searching for validation rules related to registration/create ==="
rg -n "password.*confirmed|'email'|'name'" app/ --type=php | grep -i "rules|validate" | head -20


</function_calls>

<function_calls>


#!/bin/bash

Check if password is explicitly hashed somewhere before being passed to create

echo "=== Checking if password is hashed before User::create ==="
rg -n "Hash::|bcrypt" app/Actions/Fortify/ -B5 -A5

echo ""
echo "=== Check Fortify Provider configuration ==="
find . -path ./node_modules -prune -o -name "FortifyServiceProvider*" -print


</function_calls>

<function_calls>


#!/bin/bash

Let's check the Laravel framework's default User model setup

echo "=== Checking if setPasswordAttribute mutator exists elsewhere in codebase ==="
rg -rn "setPasswordAttribute" --type=php

echo ""
echo "=== Check Laravel's Authenticatable trait default behavior ==="
echo "Looking for password-related attributes in User:"
rg -n "protected.*password|'password'.*hidden" app/Models/User.php


</function_calls>

<function_calls>


#!/bin/bash

Search for any validation being applied

echo "=== Looking for validation implementation ==="
rg -n "validate|Validator|Request" app/Actions/Fortify/CreateNewUser.php

echo ""
echo "=== Full CreateNewUser action ==="
cat app/Actions/Fortify/CreateNewUser.php

echo ""
echo "=== Full User model ==="
cat app/Models/User.php


</function_calls>

<function_calls>


#!/bin/bash

Check if this is Laravel Fortify by looking at providers and config

echo "=== Check if Fortify is installed ==="
grep -r "Fortify" config/ composer.json 2>/dev/null | head -10

echo ""
echo "=== Look for where CreateNewUser action is registered/called ==="
rg -rn "CreateNewUser|CreatesNewUsers" --type=php


</function_calls>

<function_calls>


#!/bin/bash

Look for provider or controller that handles registration requests

echo "=== Searching for registration handlers ==="
find app -name "*.php" -type f -exec grep -l "CreateNewUser|register" {} ; | head -20

echo ""
echo "=== Check Provider registration ==="
if [ -f "app/Providers/FortifyServiceProvider.php" ]; then
cat app/Providers/FortifyServiceProvider.php
fi


</function_calls>

<function_calls>


#!/bin/bash

Final check: look for where password might be hashed in the request handling

echo "=== Search for bcrypt or Hash in entire app directory ==="
rg -n "bcrypt|Hash::make" app/ --type=php

echo ""
echo "=== Check for any middleware or service that handles password ==="
rg -n "password.*hash|hash.*password" app/ --type=php -i


</function_calls>

Based on my verification, I can now provide the rewritten review comment:


Password hashing must be verified—this action doesn't hash passwords, relying entirely on upstream handling.

The create() method passes the raw input array directly to User::create() with no explicit password hashing. Laravel's Authenticatable trait does NOT automatically hash passwords on create; the password is stored as-is.

This is only safe if:

  1. The input array's password field is pre-hashed before reaching this action
  2. This action is exclusively called from a validated request handler that hashes the password beforehand

If neither condition is met, plaintext passwords will be stored in the database.

Verify that:

  • Passwords are hashed before this action receives them (e.g., in a request validation or controller layer)
  • Or add password hashing here: 'password' => Hash::make($input['password'])

Comment on lines 5 to 12
branches:
- develop
- main
- master
pull_request:
branches:
- develop
- main
- master

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

CI migrate step may be pointing at the wrong database

The new php artisan migrate --force step is where things can go sideways:

  • In CI, php artisan migrate will use .env, not the sqlite :memory: config from phpunit.xml. If .env.example is still MySQL‑by‑default, this step can blow up unless you’ve actually wired a DB service + secrets.
  • Your tests then run with Pest, which do honor phpunit.xml and use sqlite in memory, so you’re effectively migrating one DB and testing against a different one.

Consider one of these saner options:

  • Configure DB env vars in the workflow before running migrate so it uses the same sqlite connection as phpunit; or
  • Drop the global migrate step and rely on Laravel’s usual testing traits (RefreshDatabase, etc.) to handle migrations per test; or
  • Run php artisan migrate --env=testing --force and ensure config('database') for testing matches what phpunit uses.

Also, you’re doing full npm ci + npm run build for a PHP test job. If there are no JS/E2E tests in this workflow, that’s just burning minutes for fun. Split JS builds into their own workflow or job if you don’t need them here.

Also applies to: 49-50

🤖 Prompt for AI Agents
In .github/workflows/tests.yml around lines 5-12 (and also apply to lines
49-50), the CI job runs php artisan migrate which will pick up .env (likely
MySQL) while phpunit uses sqlite :memory:, and the job also runs npm build
unnecessarily; fix by (a) removing the global migrate step and relying on test
traits (RefreshDatabase) OR configuring DB env vars in the workflow before
migrate to point to the same sqlite in-memory connection phpunit uses OR run
migrate with --env=testing and ensure the testing database config matches
phpunit.xml, and (b) split out or remove npm ci + npm run build from this
PHP-only test job so JS builds run only in a dedicated job/workflow when needed.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

@julian-richter
Copy link
Member Author

julian-richter commented Nov 30, 2025

⚠️ **This documentation is outdated and has been superseded by a newer version**
⚠️ **This documentation is outdated and has been superseded by a newer version**## 📚 Automated Documentation Summary

Pull Request das-22: Implement Missing API Routes & OpenAPI Specification

This PR adds complete API endpoint implementations for authentication, user management, two-factor authentication, and session handling.
It standardizes responses, configures throttling, and introduces an Artisan command to generate an OpenAPI spec automatically.

API Throttling Configuration

Environment variables have been added to control rate limiting for different API groups.

Variable Description Default
API_THROTTLE_MAX_ATTEMPTS Max requests per decay period (general endpoints) 100
API_THROTTLE_DECAY_MINUTES Decay period in minutes for resetting general attempts 1
API_THROTTLE_AUTH_MAX_ATTEMPTS Max auth attempts per decay period (login, register, etc.) 5
API_THROTTLE_AUTH_DECAY_MINUTES Decay period in minutes for resetting auth attempts 1
API_THROTTLE_PASSWORD_RESET_MAX_ATTEMPTS Max password reset requests per decay period 3
API_THROTTLE_PASSWORD_RESET_DECAY_MINUTES Decay period in minutes for resetting password reset attempts 15
# .env.example snippet
API_THROTTLE_MAX_ATTEMPTS=100
API_THROTTLE_DECAY_MINUTES=1
API_THROTTLE_AUTH_MAX_ATTEMPTS=5
API_THROTTLE_AUTH_DECAY_MINUTES=1
API_THROTTLE_PASSWORD_RESET_MAX_ATTEMPTS=3
API_THROTTLE_PASSWORD_RESET_DECAY_MINUTES=15

Development Guideline Updates

The following documentation files now include rector/rector (RECTOR) - v2 in the list of tooling:

  • .junie/guidelines.md
  • AGENTS.md
  • CLAUDE.md
  • GEMINI.md

Inline Validation Removed in Registration Action

The CreateNewUser action no longer performs inline validation; it delegates validation to the RegisterRequest FormRequest.

// app/Actions/Fortify/CreateNewUser.php
public function create(array $input): User
{
    // Validator::make(...) removed
    return User::create([
        'name'     => $input['name'],
        'email'    => $input['email'],
        'password' => Hash::make($input['password']),
    ]);
}

OpenAPI Specification Generator ✨

A new Artisan command generates an OpenAPI 3.0.3 YAML file by introspecting API routes, controllers, and FormRequest rules.

Usage

php artisan openapi:generate --output=path/to/openapi.yaml
  • Routes filtered by name (api.v1.*), middleware (apiThrottle, api), or URI prefix (api/v1).
  • Automatic summaries extracted from route names, attributes (#[Operation]), or docblocks.
  • Request bodies generated from FormRequest rules via reflection.
  • Rate-limit responses included for status 429.
# Example output path
storage/openapi.yaml

Standardized API Responses 📦

The App\Helpers\ApiResponse class offers static methods to build uniform JSON responses:

Method Purpose Status Code
success($data, $msg) Successful operation 200
error($msg, $errors) Generic error 400
validationError($errs) Validation failures 422
unauthorized($msg) Unauthorized access 401
forbidden($msg) Access forbidden 403
notFound($msg) Resource not found 404
rateLimited($retryAfter) Rate limit exceeded 429
// Example
return ApiResponse::success(['id'=>1], 'User created', 201);

Authentication Endpoints

POST /api/v1/register

Registers a new user and issues an API token.

{
  "title": "Register User",
  "description": "Create a new user account with credentials",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/register",
  "headers": [
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"John Doe\",\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\"\n}",
  "responses": {
    "201": {
      "description": "User registered successfully",
      "body": "{\n  \"success\": true,\n  \"message\": \"User registered successfully\",\n  \"data\": { \"user\": { \"id\":1, \"name\":\"John Doe\" }, \"token\":\"<token>\", \"token_type\":\"Bearer\" }\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed\",\n  \"errors\": { \"email\": [\"The email has already been taken.\"] }\n}"
    }
  }
}

POST /api/v1/login

Authenticates a user and returns an access token.

{
  "title": "User Login",
  "description": "Authenticate user and get token",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/login",
  "headers": [
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Login successful",
      "body": "{\n  \"success\": true,\n  \"message\": \"Login successful\",\n  \"data\": { \"user\": { \"id\":1 }, \"token\": \"<token>\", \"token_type\": \"Bearer\" }\n}"
    },
    "422": {
      "description": "Invalid credentials",
      "body": "{\n  \"success\": false,\n  \"message\": \"The provided credentials are incorrect.\",\n  \"errors\": { \"email\": [\"The provided credentials are incorrect.\"] }\n}"
    }
  }
}

POST /api/v1/logout

Revokes the current token or session.

{
  "title": "Logout",
  "description": "Logout from current device",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Successfully logged out",
      "body": "{\n  \"success\": true,\n  \"message\": \"Successfully logged out from this device.\",\n  \"data\": null\n}"
    },
    "401": {
      "description": "Invalid token",
      "body": "{\n  \"success\": false,\n  \"message\": \"Invalid or expired authentication token.\",\n  \"error_code\": \"INVALID_TOKEN\"\n}"
    }
  }
}

POST /api/v1/logout-all

Revokes all tokens for the authenticated user.

{
  "title": "Logout All Devices",
  "description": "Logout from all devices",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout-all",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Successfully logged out from all devices",
      "body": "{\n  \"success\": true,\n  \"message\": \"Successfully logged out from all devices.\",\n  \"data\": null\n}"
    },
    "400": {
      "description": "Token authentication required",
      "body": "{\n  \"success\": false,\n  \"message\": \"This endpoint requires token-based authentication.\",\n  \"error_code\": \"TOKEN_AUTH_REQUIRED\"\n}"
    }
  }
}

User & Profile Endpoints

GET /api/v1/user

Retrieves the authenticated user’s profile.

{
  "title": "Get Authenticated User",
  "description": "Get authenticated user information",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Successful operation",
      "body": "{\n  \"success\": true,\n  \"message\": \"Operation successful\",\n  \"data\": { \"user\": { \"id\":1, \"email_verified_at\":null } }\n}"
    }
  }
}

PUT /api/v1/user/profile

Updates name/email and triggers re-verification if email changes.

{
  "title": "Update User Profile",
  "description": "Update user profile",
  "method": "PUT",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/profile",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"Jane Doe\",\n  \"email\": \"[email protected]\",\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Profile updated successfully",
      "body": "{\n  \"success\": true,\n  \"message\": \"Profile updated successfully.\",\n  \"data\": { \"user\": { \"email_verified_at\": null } }\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed\",\n  \"errors\": { \"email\": [\"The email must be a valid email address.\"] }\n}"
    }
  }
}

PUT /api/v1/user/password

Updates the authenticated user’s password.

{
  "title": "Update Password",
  "description": "Update user password",
  "method": "PUT",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/password",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"oldPass123\",\n  \"password\": \"newPass123\",\n  \"password_confirmation\": \"newPass123\"\n}",
  "responses": {
    "200": {
      "description": "Password updated successfully",
      "body": "{\n  \"success\": true,\n  \"message\": \"Password updated successfully.\",\n  \"data\": null\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed\",\n  \"errors\": { \"password\": [\"The password confirmation does not match.\"] }\n}"
    }
  }
}

POST /api/v1/user/deactivate

Deactivates the user account and revokes all tokens.

{
  "title": "Deactivate Account",
  "description": "Deactivate user account",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/deactivate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\",\n  \"reason\": \"No longer needed\"\n}",
  "responses": {
    "200": {
      "description": "Account deactivated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been deactivated successfully. All sessions have been terminated.\",\n  \"data\": null\n}"
    },
    "400": {
      "description": "Already deactivated",
      "body": "{\n  \"success\": false,\n  \"message\": \"Account is already deactivated.\",\n  \"error_code\": \"ACCOUNT_ALREADY_DEACTIVATED\"\n}"
    }
  }
}

POST /api/v1/user/reactivate

Reactivates a deactivated account after password confirmation.

{
  "title": "Reactivate Account",
  "description": "Reactivate user account",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/reactivate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Account reactivated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been reactivated successfully.\",\n  \"data\": null\n}"
    },
    "400": {
      "description": "Already active or wrong password",
      "body": "{\n  \"success\": false,\n  \"message\": \"The provided password does not match your current password.\",\n  \"errors\": { \"current_password\": [\"The provided password does not match your current password.\"] }\n}"
    }
  }
}

DELETE /api/v1/user/account

Deletes the user account with GDPR-compliant cleanup.

{
  "title": "Delete Account",
  "description": "Delete user account",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/account",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Account deleted",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been deleted successfully.\",\n  \"data\": null\n}"
    },
    "401": {
      "description": "Unauthorized or invalid password",
      "body": "{\n  \"success\": false,\n  \"message\": \"The provided password does not match your current password.\",\n  \"errors\": { \"current_password\": [\"The provided password does not match your current password.\"] }\n}"
    },
    "500": {
      "description": "Deletion failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"ACCOUNT_DELETION_FAILED\",\n  \"error_code\": \"ACCOUNT_DELETION_FAILED\"\n}"
    }
  }
}

Password Reset Endpoints

POST /api/v1/forgot-password

Requests a password reset link.

{
  "title": "Forgot Password",
  "description": "Request password reset",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/forgot-password",
  "headers": [
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\"\n}",
  "responses": {
    "200": {
      "description": "Link sent",
      "body": "{\n  \"success\": true,\n  \"message\": \"Password reset link sent to your email address.\",\n  \"data\": null\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed\",\n  \"errors\": { \"email\": [\"We can't find a user with that email address.\"] }\n}"
    }
  }
}

POST /api/v1/reset-password

Resets the password using token.

{
  "title": "Reset Password",
  "description": "Reset password with token",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/reset-password",
  "headers": [
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"token\": \"reset-token-here\",\n  \"password\": \"newPass123\",\n  \"password_confirmation\": \"newPass123\"\n}",
  "responses": {
    "200": {
      "description": "Password reset successful",
      "body": "{\n  \"success\": true,\n  \"message\": \"Password has been reset successfully.\",\n  \"data\": null\n}"
    },
    "422": {
      "description": "Validation failed",
      "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed\",\n  \"errors\": { \"token\": [\"This password reset token is invalid.\"] }\n}"
    }
  }
}

Email Verification Endpoints

POST /api/v1/email/verification-notification

Sends an email verification link.

{
  "title": "Send Verification Email",
  "description": "Send email verification notification",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verification-notification",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Link sent",
      "body": "{\n  \"success\": true,\n  \"message\": \"Email verification link sent successfully.\",\n  \"data\": null\n}"
    },
    "400": {
      "description": "Already verified",
      "body": "{\n  \"success\": false,\n  \"message\": \"Email is already verified.\",\n  \"error_code\": \"EMAIL_ALREADY_VERIFIED\"\n}"
    }
  }
}

POST /api/v1/email/verify

Verifies the user’s email via signed URL.

{
  "title": "Verify Email",
  "description": "Verify user email",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verify",
  "headers": [
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"id\": 1,\n  \"hash\": \"verification-hash-here\",\n  \"signature\": \"signed-string\"\n}",
  "responses": {
    "200": {
      "description": "Email verified",
      "body": "{\n  \"success\": true,\n  \"message\": \"Email verified successfully.\",\n  \"data\": { \"user\": { \"email_verified_at\": \"2025-01-01T00:00:00Z\" } }\n}"
    },
    "400": {
      "description": "Invalid link or missing signature",
      "body": "{\n  \"success\": false,\n  \"message\": \"Invalid verification link.\",\n  \"error_code\": \"INVALID_VERIFICATION_LINK\"\n}"
    }
  }
}

Session Management Endpoints

GET /api/v1/user/sessions

Lists all active API sessions.

{
  "title": "List Sessions",
  "description": "List active user sessions",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Sessions listed",
      "body": "{\n  \"success\": true,\n  \"message\": \"Operation successful\",\n  \"data\": { \"sessions\": [...], \"total\": 3 }\n}"
    }
  }
}

DELETE /api/v1/user/sessions/{sessionId}

Revokes a specific session (except current).

{
  "title": "Revoke Session",
  "description": "Revoke a user session",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions/123",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "pathParams": [
    { "key": "sessionId", "value": "Session ID to revoke", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Session revoked",
      "body": "{\n  \"success\": true,\n  \"message\": \"Session revoked successfully.\",\n  \"data\": null\n}"
    },
    "404": {
      "description": "Session not found",
      "body": "{\n  \"success\": false,\n  \"message\": \"Session not found.\",\n  \"error_code\": \"SESSION_NOT_FOUND\"\n}"
    }
  }
}

Two-Factor Authentication Endpoints

POST /api/v1/user/two-factor/enable

Initiates 2FA setup and returns QR code URL.

{
  "title": "Enable Two-Factor",
  "description": "Enable two-factor authentication",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/enable",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Setup initiated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Two-factor authentication setup initiated.\",\n  \"data\": { \"secret\": \"ABC123...\", \"qr_code_url\": \"https://...\", \"next_step\": \"Scan QR code...\" }\n}"
    },
    "400": {
      "description": "Already enabled",
      "body": "{\n  \"success\": false,\n  \"message\": \"Two-factor authentication is already enabled.\",\n  \"error_code\": \"TWO_FACTOR_ALREADY_ENABLED\"\n}"
    }
  }
}

POST /api/v1/user/two-factor/confirm

Confirms 2FA with code and returns recovery codes.

{
  "title": "Confirm Two-Factor",
  "description": "Confirm two-factor authentication",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/confirm",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"code\": \"123456\",\n  \"password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA enabled",
      "body": "{\n  \"success\": true,\n  \"message\": \"Two-factor authentication has been enabled successfully.\",\n  \"data\": { \"recovery_codes\": [\"ABCD12...\"], \"warning\": \"Save these codes.\" }\n}"
    },
    "422": {
      "description": "Invalid code",
      "body": "{\n  \"success\": false,\n  \"message\": \"Invalid two-factor authentication code.\",\n  \"error_code\": \"INVALID_TWO_FACTOR_CODE\"\n}"
    }
  }
}

GET /api/v1/user/two-factor/recovery-codes

Retrieves current recovery codes.

{
  "title": "Get Recovery Codes",
  "description": "Get two-factor recovery codes",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Codes retrieved",
      "body": "{\n  \"success\": true,\n  \"message\": \"Recovery codes retrieved successfully.\",\n  \"data\": { \"recovery_codes\": [\"ABCD12...\"], \"warning\": \"Save these codes.\" }\n}"
    },
    "400": {
      "description": "Not enabled",
      "body": "{\n  \"success\": false,\n  \"message\": \"Two-factor authentication is not enabled.\",\n  \"error_code\": \"TWO_FACTOR_NOT_ENABLED\"\n}"
    }
  }
}

POST /api/v1/user/two-factor/recovery-codes/regenerate

Regenerates and returns new recovery codes.

{
  "title": "Regenerate Recovery Codes",
  "description": "Regenerate two-factor recovery codes",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes/regenerate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Codes regenerated",
      "body": "{\n  \"success\": true,\n  \"message\": \"Recovery codes regenerated successfully.\",\n  \"data\": { \"recovery_codes\": [\"XYZ789...\"], \"warning\": \"Save these new codes.\" }\n}"
    }
  }
}

POST /api/v1/user/two-factor/disable

Disables two-factor authentication.

{
  "title": "Disable Two-Factor",
  "description": "Disable two-factor authentication",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/disable",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true },
    { "key": "Content-Type", "value": "application/json", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA disabled",
      "body": "{\n  \"success\": true,\n  \"message\": \"Two-factor authentication has been disabled successfully.\",\n  \"data\": null\n}"
    },
    "400": {
      "description": "Not enabled",
      "body": "{\n  \"success\": false,\n  \"message\": \"Two-factor authentication is not enabled.\",\n  \"error_code\": \"TWO_FACTOR_NOT_ENABLED\"\n}"
    }
  }
}

Summary

This PR delivers:

  • Complete API surface for user auth, profile, password, email verification, sessions, and 2FA.
  • Uniform JSON responses via ApiResponse.
  • Rate limiting middleware and configuration.
  • A powerful OpenAPI generator for up-to-date API specs.

🎉 All endpoints are now documented, validated, and protected by rate limits and security checks.


This documentation was automatically generated by DocuWriter.ai 🚀

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 22

♻️ Duplicate comments (20)
.github/workflows/tests.yml (1)

67-68: You're still migrating the wrong database.

The past review nailed it: this migrate step hits .env (probably MySQL) while your tests use sqlite :memory: from phpunit.xml. You're literally prepping one DB and testing a completely different one.

Drop this global migrate step and let RefreshDatabase handle it in your tests, or wire up DB env vars before this line to point at the same sqlite connection your tests use.

app/Http/Requests/Api/Auth/DeleteAccountRequest.php (1)

15-18: Same redundant auth check as DeactivateAccountRequest.

If your route already has auth:sanctum middleware (which Line 12 says it MUST), this auth()->check() is redundant. The middleware already bounced unauthenticated requests.

Defense-in-depth is fine if you're paranoid, but you can simplify to return true; if you trust your route definitions.

     public function authorize(): bool
     {
-        return auth()->check();
+        return true;
     }
app/Console/Commands/GenerateOpenApiSpec.php (4)

109-110: $schemas is still an empty array - dead variable.

Past review called this out. You declare it, never populate it, then pass the empty array to components.schemas. Either implement schema extraction or remove it.

-        $schemas = [];
-
         foreach ($routes as $route) {

And at line 145:

             'components' => [
-                'schemas' => $schemas,
+                'schemas' => new \stdClass(), // Empty object for valid OpenAPI
                 'securitySchemes' => [

487-527: Still parsing min/max but throwing them away, and numericinteger is still wrong.

Past review flagged this exact issue. Three problems persist:

  1. $minLength and $maxLength are parsed but never added to the OpenAPI property
  2. $isRequired is tracked but discarded (duplicate of getRequiredFieldsFromRules)
  3. numeric should map to number, not integer - Laravel's numeric allows floats like 3.14

The static analysis even flags the unused variables.

     private function convertFieldRulesToOpenApiProperty(array $rules, string $fieldName): array
     {
         $property = [
             'type' => 'string', // Default type
         ];

-        $isRequired = false;
         $isEmail = false;
         $isPassword = false;
-        $minLength = null;
-        $maxLength = null;

         foreach ($rules as $rule) {
             if (is_string($rule)) {
-                if ($rule === 'required') {
-                    $isRequired = true;
-                } elseif ($rule === 'email') {
+                if ($rule === 'email') {
                     $isEmail = true;
                     $property['format'] = 'email';
                 } elseif ($rule === 'password') {
                     $isPassword = true;
                     $property['format'] = 'password';
                 } elseif (str_starts_with($rule, 'min:')) {
-                    $minLength = (int) substr($rule, 4);
+                    $property['minLength'] = (int) substr($rule, 4);
                 } elseif (str_starts_with($rule, 'max:')) {
-                    $maxLength = (int) substr($rule, 4);
-                } elseif ($rule === 'integer' || $rule === 'numeric') {
+                    $property['maxLength'] = (int) substr($rule, 4);
+                } elseif ($rule === 'integer') {
                     $property['type'] = 'integer';
+                } elseif ($rule === 'numeric') {
+                    $property['type'] = 'number';
                 } elseif ($rule === 'boolean') {
                     $property['type'] = 'boolean';
                 }
             }
         }

674-689: Dead code alert: createMockFormRequest is never called.

Past review flagged this. The method ignores its $formRequestClass parameter and always returns an anonymous class. The comment says "kept for backward compatibility" in a brand new file. That's not how backward compatibility works.

Delete it.

-    /**
-     * Create a mock FormRequest instance without triggering validation.
-     */
-    private function createMockFormRequest(string $formRequestClass): \Illuminate\Foundation\Http\FormRequest
-    {
-        // This method is kept for backward compatibility but we now use extractRulesWithoutValidation
-        return new class extends \Illuminate\Foundation\Http\FormRequest
-        {
-            public function authorize(): bool
-            {
-                return true;
-            }
-
-            public function rules(): array
-            {
-                return [];
-            }
-        };
-    }

584-627: getRequestProperties and getRequiredFields are 90% copy-paste.

Past review flagged this. Both methods do the same dance: resolve controller → get FormRequest → reflect → extract rules. The only difference is what they return at the end. When you fix a bug in one, you'll forget the other.

Extract a shared helper that returns the rules, then call it from both.

Also applies to: 694-734

app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)

7-44: This is actually solid — past security concern has been addressed

The previous review flagged the missing :sanctum guard on current_password, and you fixed it (line 28). The code now properly specifies current_password:sanctum which prevents the guard context bug where Laravel defaults to web guard instead of your Sanctum-authenticated user.

The defensive auth()->check() in authorize() is good practice even though it's redundant with middleware — defense in depth is underrated.

One tiny nit: That comment about "MUST have auth:sanctum middleware" is a smell — if it MUST have it, why not enforce it at the FormRequest level? But honestly, middleware enforcement is the right place for this, so the comment is just documentation. Ship it.

app/Http/Controllers/Api/Auth/UserController.php (1)

10-24: Previous security issue? Fixed. This is clean.

You're explicitly projecting safe fields instead of yeeting the entire User model at the client. This addresses the past review concern about accidentally leaking 2FA secrets, recovery codes, or other spicy attributes.

The field selection is minimal and safe: id, name, email, email_verified_at. No hidden landmines.

Optional consideration: Users might want to know their two_factor_enabled status in this response. Not a blocker, but if the frontend needs it, you'll be back here adding it later. Plan ahead or pay in PRs.

storage/postman/api_collection (3)

80-84: Still missing password_confirmation in register payload.

This was flagged in a previous review. If your RegisterRequest validates password_confirmation (which it should for user registration), this sample payload will fail validation.


158-161: Reset password payload also missing password_confirmation.

Same issue as register - if the endpoint requires password confirmation, the sample needs to include it.


377-380: Password update payload missing password_confirmation.

Pattern continues. Either your validation doesn't require confirmation (questionable for password changes), or your Postman samples are incomplete.

app/Services/UserDeletionService.php (3)

35-41: Still logging PII in the "GDPR-compliant" deletion service. The irony.

You fixed this in softDeleteUser by hashing the email, but deleteUser still logs the raw email three times (lines 37, 81, 91). Pick one approach and be consistent. If you're deleting user data for GDPR compliance, immortalizing their email in your audit logs kind of defeats the purpose.

 Log::info('User account deletion initiated', [
     'user_id' => $user->id,
-    'user_email' => $user->email,
+    'user_email_hash' => hash('sha256', $user->email),
     'reason' => $reason,
     'deleted_at' => now(),
     'gdpr_compliant' => true,
 ]);

Apply the same fix to lines 81 and 91.


64-70: This Pulse cleanup is still a performance landmine.

LIKE '%user_id%' and LIKE '%user_email%' on what could be a massive pulse_entries table? Full table scan every time. No index can save you.

Worse: you could match false positives. If user ID 123 exists, this query also deletes entries containing "1234", "12345", or anything that contains "123" as a substring.

Laravel Pulse doesn't store user-identifiable data by default. If you've added custom recorders that do, add a proper user_id column with an index. Otherwise, just delete this block.

-                if (Schema::hasTable('pulse_entries')) {
-                    // Pulse might store user-related metrics, clean them up
-                    DB::table('pulse_entries')
-                        ->where('key', 'like', '%'.$user->id.'%')
-                        ->orWhere('key', 'like', '%'.$user->email.'%')
-                        ->delete();
-                }
+                // Note: Laravel Pulse doesn't store user-identifiable data by default.
+                // If custom Pulse recorders store user data, add a proper user_id column
+                // with an index rather than using LIKE pattern matching.

87-98: Logging the full stack trace is overkill for audit logs.

'trace' => $e->getTraceAsString() dumps the entire stack trace into your logs. That's a lot of noise for an audit trail. The error message should be sufficient for debugging, and you've already got report() or Laravel's exception handler for detailed traces.

             Log::error('User account deletion failed', [
                 'user_id' => $user->id,
-                'user_email' => $user->email,
+                'user_email_hash' => hash('sha256', $user->email),
                 'error' => $e->getMessage(),
-                'trace' => $e->getTraceAsString(),
             ]);

If you need the trace, use report($e) which respects your logging configuration.

storage/openapi.yaml (3)

1-9: Global security and hardcoded server URL - already flagged in previous review.

No global security field defined, and http://localhost is marginally better than https://dashops.test but still useless for anyone not on your machine. Previous review already covered both of these.


186-205: Missing sessionId path parameter - still not fixed from previous review.

The path /api/v1/user/sessions/{sessionId} has a path parameter but no parameter definition. OpenAPI validators will choke on this, and API consumers have no idea what type sessionId should be.


106-125: Protected endpoints still missing 401 responses - previous review flagged this.

Every endpoint with bearerAuth should document what happens when auth fails. Right now you only show 200 and 429. Clients integrating with your API are flying blind.

app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1)

27-36: Still using loose equality for ID comparisons - previous review flagged this.

Lines 28 and 34 use != instead of !==. Type juggling with IDs is how you get bugs that only surface in production when someone's ID is "1" vs 1. The fix was already suggested in the previous review.

-        if ($routeUserId && $routeUserId != $authenticatedUser->getKey()) {
+        if ($routeUserId && (string) $routeUserId !== (string) $authenticatedUser->getKey()) {
             return false;
         }

         // Check if user ID is provided in request body (defensive programming)
         $requestUserId = $this->input('user_id') ?? $this->input('id');
-        if ($requestUserId && $requestUserId != $authenticatedUser->getKey()) {
+        if ($requestUserId && (string) $requestUserId !== (string) $authenticatedUser->getKey()) {
             return false;
         }
tests/Feature/ApiTest.php (1)

972-995: Using invalid 2FA secret - previous review flagged this.

'two_factor_secret' => 'test-secret' is not a valid base32-encoded Google Authenticator secret. This test passes because you're only checking recovery code retrieval, not OTP validation. If someone copies this pattern for OTP validation tests, they'll be confused.

Use a valid base32 secret like 'JBSWY3DPEHPK3PXP' for consistency.

app/Http/Controllers/Api/Auth/TwoFactorController.php (1)

18-48: Returning raw TOTP secret is intentional but document the security implications.

Previous review flagged this. Yes, users need the secret for "can't scan QR" flows, but this endpoint's response should never be logged. Add a comment or ensure your logging middleware excludes this response body.

The implementation is acceptable for 2FA setup flows, but document the sensitivity.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to Reviews > Disable Cache setting

📥 Commits

Reviewing files that changed from the base of the PR and between 88c12c8 and 3f780f6.

📒 Files selected for processing (39)
  • .cursor/rules/laravel-boost.mdc (1 hunks)
  • .env.example (1 hunks)
  • .github/copilot-instructions.md (1 hunks)
  • .github/workflows/lint.yml (1 hunks)
  • .github/workflows/tests.yml (3 hunks)
  • .junie/guidelines.md (1 hunks)
  • AGENTS.md (1 hunks)
  • CLAUDE.md (1 hunks)
  • GEMINI.md (1 hunks)
  • app/Console/Commands/GenerateOpenApiSpec.php (1 hunks)
  • app/Helpers/ApiResponse.php (1 hunks)
  • app/Http/Controllers/Api/Auth/AccountController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/EmailVerificationController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/LoginController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/LogoutController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/PasswordController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/ProfileController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/RegisterController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/SessionController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/TwoFactorController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/UserController.php (1 hunks)
  • app/Http/Controllers/Settings/ProfileController.php (3 hunks)
  • app/Http/Middleware/ApiThrottle.php (1 hunks)
  • app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/DeleteAccountRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/RegisterRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1 hunks)
  • app/Services/UserDeletionService.php (1 hunks)
  • app/Support/TimeFormatter.php (1 hunks)
  • database/migrations/2025_11_30_174945_add_indexes_to_user_account_management_columns.php (1 hunks)
  • routes/api.php (1 hunks)
  • storage/openapi.yaml (1 hunks)
  • storage/postman/api_collection (2 hunks)
  • tests/Feature/ApiTest.php (1 hunks)
  • tests/Feature/Settings/ProfileUpdateTest.php (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (21)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Controllers/Api/Auth/ProfileController.php (3)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • success (12-23)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1)
  • UpdateProfileRequest (8-86)
app/Models/User.php (1)
  • sendEmailVerificationNotification (66-69)
app/Http/Requests/Api/Auth/RegisterRequest.php (4)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (37-43)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (3)
  • authorize (15-40)
  • rules (47-66)
  • messages (73-85)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (3)
  • authorize (12-15)
  • rules (22-27)
  • messages (34-40)
app/Http/Controllers/Api/Auth/UserController.php (1)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • success (12-23)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (2)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (37-43)
app/Support/TimeFormatter.php (1)
app/Helpers/ApiResponse.php (1)
  • secondsToHuman (113-116)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (2)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (3)
  • authorize (15-40)
  • rules (47-66)
  • messages (73-85)
app/Http/Controllers/Api/Auth/EmailVerificationController.php (3)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • error (28-46)
  • success (12-23)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1)
  • VerifyEmailRequest (7-44)
app/Models/User.php (2)
  • User (14-70)
  • sendEmailVerificationNotification (66-69)
app/Http/Controllers/Api/Auth/RegisterController.php (4)
app/Actions/Fortify/CreateNewUser.php (2)
  • CreateNewUser (8-25)
  • create (17-24)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • success (12-23)
  • error (28-46)
app/Http/Requests/Api/Auth/RegisterRequest.php (1)
  • RegisterRequest (7-49)
app/OpenApi/Operation.php (1)
  • __construct (13-17)
app/Http/Controllers/Api/Auth/AccountController.php (4)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • error (28-46)
  • success (12-23)
app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (1)
  • DeactivateAccountRequest (7-44)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (1)
  • DeleteAccountRequest (7-42)
app/Services/UserDeletionService.php (2)
  • UserDeletionService (19-142)
  • deleteUser (30-100)
app/Http/Controllers/Api/Auth/LogoutController.php (1)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • success (12-23)
  • error (28-46)
app/Http/Controllers/Settings/ProfileController.php (1)
app/Services/UserDeletionService.php (2)
  • UserDeletionService (19-142)
  • deleteUser (30-100)
app/Http/Controllers/Api/Auth/PasswordController.php (5)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • success (12-23)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (1)
  • ForgotPasswordRequest (7-41)
app/Http/Requests/Api/Auth/ResetPasswordRequest.php (1)
  • ResetPasswordRequest (7-47)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (1)
  • UpdatePasswordRequest (7-50)
app/Models/User.php (1)
  • User (14-70)
tests/Feature/ApiTest.php (2)
app/Models/User.php (1)
  • User (14-70)
app/Actions/Fortify/CreateNewUser.php (1)
  • create (17-24)
app/Http/Controllers/Api/Auth/SessionController.php (1)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • success (12-23)
  • error (28-46)
app/Services/UserDeletionService.php (3)
app/Models/User.php (1)
  • User (14-70)
app/Http/Controllers/Api/Auth/AccountController.php (1)
  • delete (69-94)
app/Helpers/ApiResponse.php (1)
  • error (28-46)
app/Http/Middleware/ApiThrottle.php (1)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • rateLimited (88-108)
app/Http/Controllers/Api/Auth/TwoFactorController.php (4)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • error (28-46)
  • success (12-23)
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (1)
  • ConfirmTwoFactorRequest (7-47)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1)
  • DisableTwoFactorRequest (7-42)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)
  • EnableTwoFactorRequest (7-44)
app/Http/Controllers/Api/Auth/LoginController.php (3)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • success (12-23)
app/Http/Requests/Api/Auth/LoginRequest.php (1)
  • LoginRequest (7-45)
app/Models/User.php (1)
  • User (14-70)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (3)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (2)
  • authorize (15-18)
  • rules (25-30)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (2)
  • authorize (15-18)
  • rules (25-31)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (2)
  • authorize (12-15)
  • rules (22-30)
app/Helpers/ApiResponse.php (1)
app/Support/TimeFormatter.php (2)
  • secondsToHuman (15-60)
  • TimeFormatter (5-61)
🪛 Checkov (3.2.334)
storage/openapi.yaml

[high] 1-433: Ensure that the global security field has rules defined

(CKV_OPENAPI_4)


[high] 1-433: Ensure that security operations is not empty.

(CKV_OPENAPI_5)

🪛 dotenv-linter (4.0.0)
.env.example

[warning] 67-67: [ExtraBlankLine] Extra blank line detected

(ExtraBlankLine)


[warning] 75-75: [UnorderedKey] The API_THROTTLE_DECAY_MINUTES key should go before the API_THROTTLE_MAX_ATTEMPTS key

(UnorderedKey)


[warning] 81-81: [UnorderedKey] The API_THROTTLE_AUTH_DECAY_MINUTES key should go before the API_THROTTLE_AUTH_MAX_ATTEMPTS key

(UnorderedKey)


[warning] 87-87: [UnorderedKey] The API_THROTTLE_PASSWORD_RESET_DECAY_MINUTES key should go before the API_THROTTLE_PASSWORD_RESET_MAX_ATTEMPTS key

(UnorderedKey)

🪛 PHPMD (2.15.0)
app/Http/Controllers/Api/Auth/ProfileController.php

35-42: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'update'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/UserController.php

16-23: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)

app/Support/TimeFormatter.php

15-60: The method secondsToHuman() has a Cyclomatic Complexity of 13. The configured cyclomatic complexity threshold is 10. (undefined)

(CyclomaticComplexity)


15-60: The method secondsToHuman() has an NPath complexity of 972. The configured NPath complexity threshold is 200. (undefined)

(NPathComplexity)

app/Http/Controllers/Api/Auth/EmailVerificationController.php

19-19: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'sendVerificationEmail'. (undefined)

(StaticAccess)


25-25: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'sendVerificationEmail'. (undefined)

(StaticAccess)


29-29: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'sendVerificationEmail'. (undefined)

(StaticAccess)


35-35: Avoid using static access to class '\App\Models\User' in method 'verifyEmail'. (undefined)

(StaticAccess)


38-38: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


43-43: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


51-51: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


59-59: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


72-76: Avoid using static access to class '\Illuminate\Http\Request' in method 'verifyEmail'. (undefined)

(StaticAccess)


79-79: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


85-92: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/RegisterController.php

28-36: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)


54-54: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/AccountController.php

23-23: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'deactivate'. (undefined)

(StaticAccess)


36-36: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'deactivate'. (undefined)

(StaticAccess)


48-48: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method 'reactivate'. (undefined)

(StaticAccess)


49-51: Avoid using static access to class '\Illuminate\Validation\ValidationException' in method 'reactivate'. (undefined)

(StaticAccess)


56-56: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'reactivate'. (undefined)

(StaticAccess)


66-66: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'reactivate'. (undefined)

(StaticAccess)


85-90: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'delete'. (undefined)

(StaticAccess)


93-93: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'delete'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/LogoutController.php

26-26: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


29-33: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


35-40: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


42-62: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


47-47: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


49-53: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


55-60: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


63-71: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


65-70: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


83-83: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


85-88: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


90-95: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


100-105: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


112-117: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)


124-124: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)


126-129: Avoid using static access to class '\Log' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)


131-136: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)

app/Http/Controllers/Settings/ProfileController.php

73-73: Avoid using static access to class '\Illuminate\Support\Facades\Auth' in method 'destroy'. (undefined)

(StaticAccess)


82-86: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'destroy'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/PasswordController.php

18-20: Avoid using static access to class '\Illuminate\Support\Facades\Password' in method 'forgotPassword'. (undefined)

(StaticAccess)


23-23: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'forgotPassword'. (undefined)

(StaticAccess)


26-28: Avoid using static access to class '\Illuminate\Validation\ValidationException' in method 'forgotPassword'. (undefined)

(StaticAccess)


33-43: Avoid using static access to class '\Illuminate\Support\Facades\Password' in method 'resetPassword'. (undefined)

(StaticAccess)


46-46: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'resetPassword'. (undefined)

(StaticAccess)


49-51: Avoid using static access to class '\Illuminate\Validation\ValidationException' in method 'resetPassword'. (undefined)

(StaticAccess)


68-71: The method updatePassword uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


73-73: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'updatePassword'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/SessionController.php

32-35: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'index'. (undefined)

(StaticAccess)


46-46: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'destroy'. (undefined)

(StaticAccess)


51-51: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'destroy'. (undefined)

(StaticAccess)


56-56: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'destroy'. (undefined)

(StaticAccess)

app/Services/UserDeletionService.php

32-99: Avoid using static access to class '\Illuminate\Support\Facades\DB' in method 'deleteUser'. (undefined)

(StaticAccess)


35-41: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'deleteUser'. (undefined)

(StaticAccess)


56-56: Avoid using static access to class '\Illuminate\Support\Facades\Schema' in method 'deleteUser'. (undefined)

(StaticAccess)


64-64: Avoid using static access to class '\Illuminate\Support\Facades\Schema' in method 'deleteUser'. (undefined)

(StaticAccess)


79-83: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'deleteUser'. (undefined)

(StaticAccess)


89-94: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'deleteUser'. (undefined)

(StaticAccess)


112-122: Avoid using static access to class '\Illuminate\Support\Facades\DB' in method 'softDeleteUser'. (undefined)

(StaticAccess)


124-128: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'softDeleteUser'. (undefined)

(StaticAccess)


133-137: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'softDeleteUser'. (undefined)

(StaticAccess)

app/Http/Middleware/ApiThrottle.php

25-68: The method handle() has a Cyclomatic Complexity of 10. The configured cyclomatic complexity threshold is 10. (undefined)

(CyclomaticComplexity)


32-32: Avoid excessively long variable names like $hasRouteLevelThrottle. Keep variable name length under 20. (undefined)

(LongVariable)


32-32: Avoid unused local variables such as '$hasRouteLevelThrottle'. (undefined)

(UnusedLocalVariable)


45-50: The method handle uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


59-64: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'handle'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/TwoFactorController.php

24-24: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'enable'. (undefined)

(StaticAccess)


43-47: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'enable'. (undefined)

(StaticAccess)


58-58: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


61-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


69-69: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


81-84: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


93-93: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


100-100: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


104-104: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


109-109: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


112-115: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


124-124: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'regenerateRecoveryCodes'. (undefined)

(StaticAccess)


135-138: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'regenerateRecoveryCodes'. (undefined)

(StaticAccess)


147-147: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'disable'. (undefined)

(StaticAccess)


157-157: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'disable'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/LoginController.php

28-28: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method '__invoke'. (undefined)

(StaticAccess)


29-31: Avoid using static access to class '\Illuminate\Validation\ValidationException' in method '__invoke'. (undefined)

(StaticAccess)


35-35: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method '__invoke'. (undefined)

(StaticAccess)


36-36: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method '__invoke'. (undefined)

(StaticAccess)


47-55: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)

app/Console/Commands/GenerateOpenApiSpec.php

13-743: The class GenerateOpenApiSpec has an overall complexity of 102 which is very high. The configured complexity threshold is 50. (undefined)

(ExcessiveClassComplexity)


38-38: Avoid using static access to class '\Symfony\Component\Yaml\Yaml' in method 'handle'. (undefined)

(StaticAccess)


74-74: Avoid using static access to class '\Illuminate\Support\Facades\Route' in method 'getApiRoutes'. (undefined)

(StaticAccess)


258-260: The method getSummaryFromController uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


413-413: Avoid using static access to class '\Illuminate\Support\Facades\Route' in method 'getControllerForUri'. (undefined)

(StaticAccess)


487-527: The method convertFieldRulesToOpenApiProperty() has a Cyclomatic Complexity of 12. The configured cyclomatic complexity threshold is 10. (undefined)

(CyclomaticComplexity)


493-493: Avoid unused local variables such as '$isRequired'. (undefined)

(UnusedLocalVariable)


496-496: Avoid unused local variables such as '$minLength'. (undefined)

(UnusedLocalVariable)


497-497: Avoid unused local variables such as '$maxLength'. (undefined)

(UnusedLocalVariable)


608-611: The method getRequestProperties uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


644-644: Missing class import via use statement (line '644', column '32'). (undefined)

(MissingImport)


674-689: Avoid unused private methods such as 'createMockFormRequest'. (undefined)

(UnusedPrivateMethod)


674-674: Avoid unused parameters such as '$formRequestClass'. (undefined)

(UnusedFormalParameter)


718-721: The method getRequiredFields uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)

app/Helpers/ApiResponse.php

115-115: Avoid using static access to class '\App\Support\TimeFormatter' in method 'secondsToHuman'. (undefined)

(StaticAccess)

🪛 YAMLlint (1.37.1)
storage/openapi.yaml

[error] 26-26: too many spaces inside braces

(braces)


[error] 26-26: too many spaces inside braces

(braces)


[error] 27-27: too many spaces inside braces

(braces)


[error] 27-27: too many spaces inside braces

(braces)


[error] 28-28: too many spaces inside braces

(braces)


[error] 28-28: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 29-29: too many spaces inside braces

(braces)


[error] 47-47: too many spaces inside braces

(braces)


[error] 47-47: too many spaces inside braces

(braces)


[error] 48-48: too many spaces inside braces

(braces)


[error] 48-48: too many spaces inside braces

(braces)


[error] 49-49: too many spaces inside braces

(braces)


[error] 49-49: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 50-50: too many spaces inside braces

(braces)


[error] 66-66: too many spaces inside braces

(braces)


[error] 66-66: too many spaces inside braces

(braces)


[error] 67-67: too many spaces inside braces

(braces)


[error] 67-67: too many spaces inside braces

(braces)


[error] 68-68: too many spaces inside braces

(braces)


[error] 68-68: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 69-69: too many spaces inside braces

(braces)


[error] 85-85: too many spaces inside braces

(braces)


[error] 85-85: too many spaces inside braces

(braces)


[error] 86-86: too many spaces inside braces

(braces)


[error] 86-86: too many spaces inside braces

(braces)


[error] 87-87: too many spaces inside braces

(braces)


[error] 87-87: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 88-88: too many spaces inside braces

(braces)


[error] 102-102: too many spaces inside braces

(braces)


[error] 102-102: too many spaces inside braces

(braces)


[error] 103-103: too many spaces inside braces

(braces)


[error] 103-103: too many spaces inside braces

(braces)


[error] 104-104: too many spaces inside braces

(braces)


[error] 104-104: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 105-105: too many spaces inside braces

(braces)


[error] 119-119: too many spaces inside braces

(braces)


[error] 119-119: too many spaces inside braces

(braces)


[error] 120-120: too many spaces inside braces

(braces)


[error] 120-120: too many spaces inside braces

(braces)


[error] 121-121: too many spaces inside braces

(braces)


[error] 121-121: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 122-122: too many spaces inside braces

(braces)


[error] 125-125: too many spaces inside empty braces

(braces)


[error] 139-139: too many spaces inside braces

(braces)


[error] 139-139: too many spaces inside braces

(braces)


[error] 140-140: too many spaces inside braces

(braces)


[error] 140-140: too many spaces inside braces

(braces)


[error] 141-141: too many spaces inside braces

(braces)


[error] 141-141: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 142-142: too many spaces inside braces

(braces)


[error] 145-145: too many spaces inside empty braces

(braces)


[error] 159-159: too many spaces inside braces

(braces)


[error] 159-159: too many spaces inside braces

(braces)


[error] 160-160: too many spaces inside braces

(braces)


[error] 160-160: too many spaces inside braces

(braces)


[error] 161-161: too many spaces inside braces

(braces)


[error] 161-161: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 162-162: too many spaces inside braces

(braces)


[error] 165-165: too many spaces inside empty braces

(braces)


[error] 179-179: too many spaces inside braces

(braces)


[error] 179-179: too many spaces inside braces

(braces)


[error] 180-180: too many spaces inside braces

(braces)


[error] 180-180: too many spaces inside braces

(braces)


[error] 181-181: too many spaces inside braces

(braces)


[error] 181-181: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 182-182: too many spaces inside braces

(braces)


[error] 185-185: too many spaces inside empty braces

(braces)


[error] 199-199: too many spaces inside braces

(braces)


[error] 199-199: too many spaces inside braces

(braces)


[error] 200-200: too many spaces inside braces

(braces)


[error] 200-200: too many spaces inside braces

(braces)


[error] 201-201: too many spaces inside braces

(braces)


[error] 201-201: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 202-202: too many spaces inside braces

(braces)


[error] 205-205: too many spaces inside empty braces

(braces)


[error] 219-219: too many spaces inside braces

(braces)


[error] 219-219: too many spaces inside braces

(braces)


[error] 220-220: too many spaces inside braces

(braces)


[error] 220-220: too many spaces inside braces

(braces)


[error] 221-221: too many spaces inside braces

(braces)


[error] 221-221: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 222-222: too many spaces inside braces

(braces)


[error] 225-225: too many spaces inside empty braces

(braces)


[error] 239-239: too many spaces inside braces

(braces)


[error] 239-239: too many spaces inside braces

(braces)


[error] 240-240: too many spaces inside braces

(braces)


[error] 240-240: too many spaces inside braces

(braces)


[error] 241-241: too many spaces inside braces

(braces)


[error] 241-241: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 242-242: too many spaces inside braces

(braces)


[error] 245-245: too many spaces inside empty braces

(braces)


[error] 259-259: too many spaces inside braces

(braces)


[error] 259-259: too many spaces inside braces

(braces)


[error] 260-260: too many spaces inside braces

(braces)


[error] 260-260: too many spaces inside braces

(braces)


[error] 261-261: too many spaces inside braces

(braces)


[error] 261-261: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 262-262: too many spaces inside braces

(braces)


[error] 265-265: too many spaces inside empty braces

(braces)


[error] 279-279: too many spaces inside braces

(braces)


[error] 279-279: too many spaces inside braces

(braces)


[error] 280-280: too many spaces inside braces

(braces)


[error] 280-280: too many spaces inside braces

(braces)


[error] 281-281: too many spaces inside braces

(braces)


[error] 281-281: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 282-282: too many spaces inside braces

(braces)


[error] 285-285: too many spaces inside empty braces

(braces)


[error] 299-299: too many spaces inside braces

(braces)


[error] 299-299: too many spaces inside braces

(braces)


[error] 300-300: too many spaces inside braces

(braces)


[error] 300-300: too many spaces inside braces

(braces)


[error] 301-301: too many spaces inside braces

(braces)


[error] 301-301: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 302-302: too many spaces inside braces

(braces)


[error] 305-305: too many spaces inside empty braces

(braces)


[error] 319-319: too many spaces inside braces

(braces)


[error] 319-319: too many spaces inside braces

(braces)


[error] 320-320: too many spaces inside braces

(braces)


[error] 320-320: too many spaces inside braces

(braces)


[error] 321-321: too many spaces inside braces

(braces)


[error] 321-321: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 322-322: too many spaces inside braces

(braces)


[error] 325-325: too many spaces inside empty braces

(braces)


[error] 338-338: too many spaces inside braces

(braces)


[error] 338-338: too many spaces inside braces

(braces)


[error] 339-339: too many spaces inside braces

(braces)


[error] 339-339: too many spaces inside braces

(braces)


[error] 340-340: too many spaces inside braces

(braces)


[error] 340-340: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 341-341: too many spaces inside braces

(braces)


[error] 344-344: too many spaces inside empty braces

(braces)


[error] 358-358: too many spaces inside braces

(braces)


[error] 358-358: too many spaces inside braces

(braces)


[error] 359-359: too many spaces inside braces

(braces)


[error] 359-359: too many spaces inside braces

(braces)


[error] 360-360: too many spaces inside braces

(braces)


[error] 360-360: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 361-361: too many spaces inside braces

(braces)


[error] 364-364: too many spaces inside empty braces

(braces)


[error] 378-378: too many spaces inside braces

(braces)


[error] 378-378: too many spaces inside braces

(braces)


[error] 379-379: too many spaces inside braces

(braces)


[error] 379-379: too many spaces inside braces

(braces)


[error] 380-380: too many spaces inside braces

(braces)


[error] 380-380: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 381-381: too many spaces inside braces

(braces)


[error] 384-384: too many spaces inside empty braces

(braces)


[error] 398-398: too many spaces inside braces

(braces)


[error] 398-398: too many spaces inside braces

(braces)


[error] 399-399: too many spaces inside braces

(braces)


[error] 399-399: too many spaces inside braces

(braces)


[error] 400-400: too many spaces inside braces

(braces)


[error] 400-400: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 401-401: too many spaces inside braces

(braces)


[error] 404-404: too many spaces inside empty braces

(braces)


[error] 418-418: too many spaces inside braces

(braces)


[error] 418-418: too many spaces inside braces

(braces)


[error] 419-419: too many spaces inside braces

(braces)


[error] 419-419: too many spaces inside braces

(braces)


[error] 420-420: too many spaces inside braces

(braces)


[error] 420-420: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 421-421: too many spaces inside braces

(braces)


[error] 424-424: too many spaces inside empty braces

(braces)


[error] 426-426: too many spaces inside empty braces

(braces)

Comment on lines +67 to +69

// Note: We cannot verify database deletion here because RefreshDatabase
// rolls back all changes. The UserDeletionService logs confirm deletion occurs.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bad: You punted on actually testing deletion.

"We cannot verify database deletion because RefreshDatabase" is a cop-out. If you're using SoftDeletes (which you are, based on the User model changes), you should be able to ->assertSoftDeleted($user) right here.

The whole point of soft deletes is they survive transaction rollbacks because they're just a flag flip. Saying "trust the logs" is not testing - it's hoping.

Apply this diff to actually test the deletion:

     $this->assertGuest();
-
-    // Note: We cannot verify database deletion here because RefreshDatabase
-    // rolls back all changes. The UserDeletionService logs confirm deletion occurs.
+    $this->assertSoftDeleted($user);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Note: We cannot verify database deletion here because RefreshDatabase
// rolls back all changes. The UserDeletionService logs confirm deletion occurs.
$this->assertGuest();
$this->assertSoftDeleted($user);
🤖 Prompt for AI Agents
In tests/Feature/Settings/ProfileUpdateTest.php around lines 67-69, the test
currently skips asserting deletion citing RefreshDatabase; instead, since the
User model uses SoftDeletes, replace the comment with an actual assertion that
the user was soft-deleted: call the deletion flow in the test, then use
$this->assertSoftDeleted($user) (or assertSoftDeleted('users', ['id' =>
$user->id'])) to verify the deleted_at flag was set; ensure $user is the same
persisted record (refresh or reload if needed) and keep RefreshDatabase—soft
deletes are visible within the transaction so no further DB setup is required.

Copy link
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@julian-richter
Copy link
Member Author

julian-richter commented Nov 30, 2025

⚠️ **This documentation is outdated and has been superseded by a newer version**## 📚 Automated Documentation Summary

PR das-22: Implement Missing API Routes & OpenAPI Specification

This pull request adds missing API endpoints under /api/v1/*, introduces a command to generate an OpenAPI spec, and standardizes JSON responses and rate-limiting. It also updates environment variables and adds a new helper and middleware.


Environment Updates

The .env.example file now includes API throttling settings to configure rate limits:

Variable Default Description
API_THROTTLE_MAX_ATTEMPTS 100 Max requests per minute for general endpoints
API_THROTTLE_DECAY_MINUTES 1 Reset window for general endpoints (minutes)
API_THROTTLE_AUTH_MAX_ATTEMPTS 5 Max auth attempts per minute (login/register)
API_THROTTLE_AUTH_DECAY_MINUTES 1 Reset auth window (minutes)
API_THROTTLE_PASSWORD_RESET_MAX_ATTEMPTS 3 Max password reset requests per period
API_THROTTLE_PASSWORD_RESET_DECAY_MINUTES 15 Reset window for password resets (minutes)
# .env.example excerpt
API_THROTTLE_MAX_ATTEMPTS=100
API_THROTTLE_DECAY_MINUTES=1
API_THROTTLE_AUTH_MAX_ATTEMPTS=5
API_THROTTLE_AUTH_DECAY_MINUTES=1
API_THROTTLE_PASSWORD_RESET_MAX_ATTEMPTS=3
API_THROTTLE_PASSWORD_RESET_DECAY_MINUTES=15

Dependency List Updates

Several project docs (.junie/guidelines.md, AGENTS.md, CLAUDE.md, GEMINI.md) now include:

  • rector/rector (RECTOR) - v2

Action: CreateNewUser Simplification

Removed explicit validation in app/Actions/Fortify/CreateNewUser.php. Validation now lives in FormRequest classes.

// Before: explicit Validator::make(...)->validate();
// After: directly create User from validated input
return User::create([
    'name'  => $input['name'],
    'email' => $input['email'],
    'password' => Hash::make($input['password']),
]);

Console Command: GenerateOpenApiSpec 📄

A new Artisan command openapi:generate inspects all api.v1.* routes and outputs an OpenAPI 3.0.3 YAML file.

  • Discovers routes by name, middleware, or URI.
  • Reflects controller docblocks or custom #[Operation] attributes for summaries.
  • Extracts request schemas from FormRequest validation rules.
  • Builds paths, components.schemas, and securitySchemes.bearerAuth.
  • Usage:
    php artisan openapi:generate --output=storage/openapi.yaml
  • Fails with clear error messages on I/O issues.

Helper: ApiResponse 💡

Located in app/Helpers/ApiResponse.php, this class standardizes JSON responses:

  • success(data, message, statusCode)
  • error(message, errors, statusCode, errorCode)
  • validationError(errors)
  • unauthorized(), forbidden(), notFound(), rateLimited()

Example:

return ApiResponse::success(
    ['user' => $user],
    'Login successful',
    200
);

Middleware: ApiThrottle 🐢

New app/Http/Middleware/ApiThrottle.php extends Laravel’s ThrottleRequests:

  • Applies different rate limits based on route name:
    • Auth endpoints: uses api.throttle.auth.* config.
    • Password reset: uses api.throttle.password_reset.*.
    • General API: uses api.throttle.default.*.
  • Returns standardized 429 JSON via ApiResponse::rateLimited().

API Endpoints

Base URL: https://api.example.com
All endpoints are under /api/v1. Each block below documents a single endpoint with examples.


POST /api/v1/user/deactivate

Deactivate authenticated user account.

{
  "title": "Deactivate Account",
  "description": "Deactivate current user account and revoke all tokens.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/deactivate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\",\n  \"reason\": \"No longer needed\"\n}",
  "responses": {
    "200": {
      "description": "Account deactivated successfully",
      "body": "{ \"success\": true, \"message\": \"Account has been deactivated successfully. All sessions have been terminated.\", \"data\": null, \"errors\": null }"
    },
    "400": {
      "description": "Bad request or already deactivated",
      "body": "{ \"success\": false, \"message\": \"Account is already deactivated.\", \"error_code\": \"ACCOUNT_ALREADY_DEACTIVATED\" }"
    },
    "401": {
      "description": "Unauthorized",
      "body": "{ \"success\": false, \"message\": \"Unauthorized access\", \"error_code\": \"UNAUTHORIZED\" }"
    }
  }
}

POST /api/v1/user/reactivate

Reactivate a deactivated account with current password.

{
  "title": "Reactivate Account",
  "description": "Reactivate user account after deactivation.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/reactivate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Reactivation successful",
      "body": "{ \"success\": true, \"message\": \"Account has been reactivated successfully.\", \"data\": null, \"errors\": null }"
    },
    "400": {
      "description": "Invalid password or already active",
      "body": "{ \"success\": false, \"message\": \"Account is already active.\", \"error_code\": \"ACCOUNT_ALREADY_ACTIVE\" }"
    },
    "422": {
      "description": "Validation failed",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": { \"current_password\": [ ... ] }, \"error_code\": \"VALIDATION_ERROR\" }"
    }
  }
}

DELETE /api/v1/user/account

Delete authenticated user account GDPR-compliantly.

{
  "title": "Delete Account",
  "description": "Permanently delete user account with cleanup.",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/account",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Account deleted successfully",
      "body": "{ \"success\": true, \"message\": \"Account has been deleted successfully.\", \"data\": null, \"errors\": null }"
    },
    "400": {
      "description": "Bad request",
      "body": "{ \"success\": false, \"message\": \"The provided password does not match your current password.\", \"error_code\": \"VALIDATION_ERROR\" }"
    },
    "500": {
      "description": "Server error",
      "body": "{ \"success\": false, \"message\": \"ACCOUNT_DELETION_FAILED\", \"error_code\": \"ACCOUNT_DELETION_FAILED\" }"
    }
  }
}

POST /api/v1/email/verification-notification

Send email verification link.

{
  "title": "Send Verification Email",
  "description": "Send verification link to user email.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verification-notification",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Link sent",
      "body": "{ \"success\": true, \"message\": \"Email verification link sent successfully.\", \"data\": null, \"errors\": null }"
    },
    "400": {
      "description": "Already verified",
      "body": "{ \"success\": false, \"message\": \"EMAIL_ALREADY_VERIFIED\", \"error_code\": \"EMAIL_ALREADY_VERIFIED\" }"
    }
  }
}

GET /api/v1/email/verify

Verify user email via signed link.

{
  "title": "Verify Email",
  "description": "Verify user email from link query params.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verify?id={id}&hash={hash}&signature={sig}",
  "pathParams": [
    { "key": "id", "value": "User ID", "required": true },
    { "key": "hash", "value": "Verification hash", "required": true }
  ],
  "headers": [],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Email verified",
      "body": "{ \"success\": true, \"message\": \"Email verified successfully.\", \"data\": { \"user\": { ... } } }"
    },
    "400": {
      "description": "Invalid or expired link",
      "body": "{ \"success\": false, \"message\": \"EXPIRED_VERIFICATION_LINK\", \"error_code\": \"EXPIRED_VERIFICATION_LINK\" }"
    },
    "404": {
      "description": "User not found",
      "body": "{ \"success\": false, \"message\": \"USER_NOT_FOUND\", \"error_code\": \"USER_NOT_FOUND\" }"
    }
  }
}

POST /api/v1/login

User authentication.

{
  "title": "User Login",
  "description": "Authenticate user and return token.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/login",
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\",\n  \"device\": \"web\"\n}",
  "responses": {
    "200": {
      "description": "Login successful",
      "body": "{ \"success\": true, \"message\": \"Login successful\", \"data\": { \"user\": { ... }, \"token\": \"<token>\", \"token_type\": \"Bearer\" } }"
    },
    "422": {
      "description": "Invalid credentials",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": {\"email\": [\"The provided credentials are incorrect.\"]}, \"error_code\":\"VALIDATION_ERROR\" }"
    }
  }
}

POST /api/v1/logout

Revoke current token or session.

{
  "title": "Logout",
  "description": "Logout from current device (token or session).",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Logout successful",
      "body": "{ \"success\": true, \"message\": \"Successfully logged out from this device.\", \"data\": null }"
    },
    "401": {
      "description": "No authentication found",
      "body": "{ \"success\": false, \"message\": \"NO_AUTHENTICATION\", \"error_code\": \"NO_AUTHENTICATION\" }"
    }
  }
}

POST /api/v1/logout-all

Revoke all tokens (token‐only).

{
  "title": "Logout All Devices",
  "description": "Revoke all Sanctum tokens for user.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout-all",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "All sessions revoked",
      "body": "{ \"success\": true, \"message\": \"Successfully logged out from all devices.\", \"data\": null }"
    },
    "400": {
      "description": "Session‐based auth not supported",
      "body": "{ \"success\": false, \"message\": \"TOKEN_AUTH_REQUIRED\", \"error_code\": \"TOKEN_AUTH_REQUIRED\" }"
    }
  }
}

POST /api/v1/forgot-password

Request password reset link.

{
  "title": "Forgot Password",
  "description": "Send password reset link via email.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/forgot-password",
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\"\n}",
  "responses": {
    "200": {
      "description": "Link sent",
      "body": "{ \"success\": true, \"message\": \"Password reset link sent to your email address.\", \"data\": null }"
    },
    "422": {
      "description": "Validation failed",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": {\"email\":[...]} }"
    }
  }
}

POST /api/v1/reset-password

Reset password with token.

{
  "title": "Reset Password",
  "description": "Reset user password using token.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/reset-password",
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"token\": \"reset-token-here\",\n  \"password\": \"newPass123\",\n  \"password_confirmation\": \"newPass123\"\n}",
  "responses": {
    "200": {
      "description": "Password reset successful",
      "body": "{ \"success\": true, \"message\": \"Password has been reset successfully.\", \"data\": null }"
    },
    "422": {
      "description": "Validation failed",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": { ... } }"
    }
  }
}

PUT /api/v1/user/password

Update password when authenticated.

{
  "title": "Update Password",
  "description": "Change password for authenticated user.",
  "method": "PUT",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/password",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"oldPass123\",\n  \"password\": \"newPass123\",\n  \"password_confirmation\": \"newPass123\"\n}",
  "responses": {
    "200": {
      "description": "Password updated",
      "body": "{ \"success\": true, \"message\": \"Password updated successfully.\", \"data\": null }"
    },
    "422": {
      "description": "Validation failed",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": { ... } }"
    }
  }
}

PUT /api/v1/user/profile

Update user profile.

{
  "title": "Update Profile",
  "description": "Update name or email for authenticated user.",
  "method": "PUT",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/profile",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"John Doe\",\n  \"email\": \"[email protected]\"\n}",
  "responses": {
    "200": {
      "description": "Profile updated",
      "body": "{ \"success\": true, \"message\": \"Profile updated successfully.\", \"data\": { \"user\": { ... } } }"
    },
    "422": {
      "description": "Validation failed",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": { ... } }"
    }
  }
}

POST /api/v1/register

Create new user and return token.

{
  "title": "User Registration",
  "description": "Register new user and issue token.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/register",
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"John Doe\",\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\",\n  \"password_confirmation\": \"password123\"\n}",
  "responses": {
    "201": {
      "description": "Registration successful",
      "body": "{ \"success\": true, \"message\": \"User registered successfully\", \"data\": { \"user\": {...}, \"token\": \"<token>\", \"token_type\":\"Bearer\" } }"
    },
    "422": {
      "description": "Validation failed",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": {...} }"
    },
    "500": {
      "description": "Server error",
      "body": "{ \"success\": false, \"message\": \"Registration failed\", \"error_code\":\"REGISTRATION_ERROR\" }"
    }
  }
}

GET /api/v1/user/sessions

List active sessions (tokens).

{
  "title": "List Sessions",
  "description": "Retrieve all active API tokens for user.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Sessions listed",
      "body": "{ \"success\": true, \"message\": \"\", \"data\": { \"sessions\": [...], \"total\": 3 } }"
    }
  }
}

DELETE /api/v1/user/sessions/{sessionId}

Revoke a specific session.

{
  "title": "Revoke Session",
  "description": "Delete a specific API token.",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions/{sessionId}",
  "pathParams": [
    { "key": "sessionId", "value": "Token ID", "required": true }
  ],
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Session revoked",
      "body": "{ \"success\": true, \"message\": \"Session revoked successfully.\", \"data\": null }"
    },
    "404": {
      "description": "Session not found",
      "body": "{ \"success\": false, \"message\": \"SESSION_NOT_FOUND\", \"error_code\":\"SESSION_NOT_FOUND\" }"
    }
  }
}

POST /api/v1/user/two-factor/enable

Initiate 2FA setup.

{
  "title": "Enable Two-Factor",
  "description": "Start two-factor authentication setup.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/enable",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Setup initiated",
      "body": "{ \"success\": true, \"message\": \"Two-factor authentication setup initiated.\", \"data\": { \"secret\":\"...\",\"qr_code_url\":\"...\",\"next_step\":\"...\" } }"
    },
    "400": {
      "description": "Already enabled",
      "body": "{ \"success\": false, \"message\": \"TWO_FACTOR_ALREADY_ENABLED\", \"error_code\":\"TWO_FACTOR_ALREADY_ENABLED\" }"
    }
  }
}

POST /api/v1/user/two-factor/confirm

Confirm 2FA code.

{
  "title": "Confirm Two-Factor",
  "description": "Verify 2FA code and enable.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/confirm",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"code\": \"123456\",\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA enabled",
      "body": "{ \"success\": true, \"message\": \"Two-factor authentication has been enabled successfully.\", \"data\": { \"recovery_codes\": [...], \"warning\":\"...\" } }"
    },
    "422": {
      "description": "Invalid code or not initiated",
      "body": "{ \"success\": false, \"message\": \"INVALID_TWO_FACTOR_CODE\", \"error_code\":\"INVALID_TWO_FACTOR_CODE\" }"
    }
  }
}

GET /api/v1/user/two-factor/recovery-codes

Retrieve current recovery codes.

{
  "title": "Get Recovery Codes",
  "description": "Fetch two-factor recovery codes.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Codes retrieved",
      "body": "{ \"success\": true, \"message\": \"Recovery codes retrieved successfully.\", \"data\": { \"recovery_codes\": [...] } }"
    },
    "400": {
      "description": "2FA not enabled",
      "body": "{ \"success\": false, \"message\": \"TWO_FACTOR_NOT_ENABLED\", \"error_code\":\"TWO_FACTOR_NOT_ENABLED\" }"
    }
  }
}

POST /api/v1/user/two-factor/recovery-codes/regenerate

Regenerate recovery codes.

{
  "title": "Regenerate Recovery Codes",
  "description": "Invalidate old and create new recovery codes.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes/regenerate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Codes regenerated",
      "body": "{ \"success\": true, \"message\": \"Recovery codes regenerated successfully.\", \"data\": { \"recovery_codes\": [...] } }"
    },
    "400": {
      "description": "2FA not enabled",
      "body": "{ \"success\": false, \"message\": \"TWO_FACTOR_NOT_ENABLED\", \"error_code\":\"TWO_FACTOR_NOT_ENABLED\" }"
    }
  }
}

DELETE /api/v1/user/two-factor/disable

Disable two-factor.

{
  "title": "Disable Two-Factor",
  "description": "Turn off two-factor authentication.",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/disable",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA disabled",
      "body": "{ \"success\": true, \"message\": \"Two-factor authentication has been disabled successfully.\", \"data\": null }"
    },
    "400": {
      "description": "2FA not enabled",
      "body": "{ \"success\": false, \"message\": \"TWO_FACTOR_NOT_ENABLED\", \"error_code\":\"TWO_FACTOR_NOT_ENABLED\" }"
    }
  }
}

GET /api/v1/user

Fetch authenticated user details.

{
  "title": "Get Authenticated User",
  "description": "Retrieve current user information.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "User data",
      "body": "{ \"success\": true, \"message\": \"\", \"data\": { \"user\": { ... } } }"
    }
  }
}

All new API routes are now documented with examples, request/response formats, and proper status codes. The OpenAPI generator can now produce an up-to-date spec automatically.


This documentation was automatically generated by DocuWriter.ai 🚀

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

♻️ Duplicate comments (14)
storage/postman/api_collection (3)

80-84: Sample payload still missing password_confirmation field.

This was flagged in a previous review. Registration endpoints typically require password confirmation for user-facing flows. The payload should include "password_confirmation": "password123".


158-162: Reset-password payload missing password_confirmation.

As noted in a previous review, this endpoint should include password_confirmation to match typical password reset validation rules.


377-381: Password update also missing password_confirmation.

Same story as register and reset-password - flagged in previous review. Add "password_confirmation": "password123" to the payload.

.github/workflows/tests.yml (1)

67-68: This migrate step is still migrating the wrong database.

Your CI job runs php artisan migrate --force which reads .env (probably MySQL). Then your tests run with Pest, which uses phpunit.xml's sqlite :memory: config. You're migrating one database and testing against a completely different one.

This was already flagged in the previous review. The fix is simple: either remove this migrate step entirely and let Laravel's RefreshDatabase trait handle migrations per test, or configure the DB env vars in the workflow to point to sqlite before running migrate.

Suggested fix:

-      - name: Run Migrations
-        run: php artisan migrate --force

And ensure your tests use RefreshDatabase trait. Or, if you really want a global migrate step:

+      - name: Configure Test Database
+        run: |
+          echo "DB_CONNECTION=sqlite" >> $GITHUB_ENV
+          echo "DB_DATABASE=:memory:" >> $GITHUB_ENV
+
       - name: Run Migrations
-        run: php artisan migrate --force
+        run: php artisan migrate --force --database=sqlite
app/Services/UserDeletionService.php (1)

30-41: Still logging raw emails in a “GDPR-compliant” deletion service

You’re back to writing user_email into logs in both hard delete and soft delete paths. For something explicitly branded as GDPR-friendly, immortalizing PII in log storage is… not that.

Use a stable non-PII identifier (ID, or a hash of the email) or just drop the email entirely. Same story for the error logs.

Example fix:

-        Log::info('User account deletion initiated', [
-            'user_id' => $user->id,
-            'user_email' => $user->email,
+        Log::info('User account deletion initiated', [
+            'user_id' => $user->id,
+            'user_email_hash' => hash('sha256', (string) $user->email),
             'reason' => $reason,
             'deleted_at' => now(),
             'gdpr_compliant' => true,
         ]);
@@
-        Log::info('User account deletion completed', [
-            'user_id' => $user->id,
-            'user_email' => $user->email,
+        Log::info('User account deletion completed', [
+            'user_id' => $user->id,
+            'user_email_hash' => hash('sha256', (string) $user->email),
             'cleanup_completed' => true,
         ]);
@@
-        Log::error('User account deletion failed', [
-            'user_id' => $user->id,
-            'user_email' => $user->email,
+        Log::error('User account deletion failed', [
+            'user_id' => $user->id,
+            'user_email_hash' => hash('sha256', (string) $user->email),
             'error' => $e->getMessage(),
             'trace' => $e->getTraceAsString(),
         ]);
@@
-        Log::info('User account soft deleted', [
-            'user_id' => $user->id,
-            'user_email' => $user->email,
+        Log::info('User account soft deleted', [
+            'user_id' => $user->id,
             'reason' => $reason,
         ]);
@@
-        Log::error('User account soft deletion failed', [
-            'user_id' => $user->id,
-            'user_email' => $user->email,
+        Log::error('User account soft deletion failed', [
+            'user_id' => $user->id,
             'error' => $e->getMessage(),
         ]);

Also applies to: 75-79, 85-90, 105-135

app/Console/Commands/GenerateOpenApiSpec.php (3)

382-406: Route → FormRequest resolution is doing extra work and can be wrong

You already have the Route $route, but you throw it away, re-scan the router by URI in getControllerForUri, then repeat that dance separately for properties and required fields.

Two problems:

  • Performance/complexity: every schema extraction walks the entire route collection again.
  • Correctness: matching only on $uri ignores HTTP method. Any GET /api/v1/foo + POST /api/v1/foo pair with different FormRequests will be mapped to whichever route happens to be “first”, so the POST body schema can easily be wrong.

Refactor to pass the actual Route through:

  • Change getRequestBodySpec to call something like getRulesForRoute(Route $route) once.
  • Implement getRulesForRoute using $route->getAction()['controller'] to resolve [controller, method].
  • Have getRequestProperties and getRequiredFields work off that rules array instead of re-computing via URI.

Net result: less magic, less over-engineering, and your OpenAPI won’t lie when you eventually add multi-method endpoints.

Also applies to: 411-435, 584-627, 685-725


487-527: Rule → OpenAPI mapping still drops min/max and mislabels numeric

convertFieldRulesToOpenApiProperty is half-finished:

  • You parse min: and max: into $minLength/$maxLength and track $isRequired, then throw them all away.
  • You map 'numeric' to OpenAPI integer, which is just wrong for floats.

The result is a spec that under-documents constraints and lies about numeric fields.

Tighten this up, e.g.:

-        $isRequired = false;
         $isEmail = false;
         $isPassword = false;
-        $minLength = null;
-        $maxLength = null;
@@
-                if ($rule === 'required') {
-                    $isRequired = true;
-                } elseif ($rule === 'email') {
+                if ($rule === 'email') {
                     $isEmail = true;
                     $property['format'] = 'email';
@@
                 } elseif (str_starts_with($rule, 'min:')) {
-                    $minLength = (int) substr($rule, 4);
+                    $min = (int) substr($rule, 4);
+                    if (($property['type'] ?? 'string') === 'string') {
+                        $property['minLength'] = $min;
+                    } else {
+                        $property['minimum'] = $min;
+                    }
                 } elseif (str_starts_with($rule, 'max:')) {
-                    $maxLength = (int) substr($rule, 4);
+                    $max = (int) substr($rule, 4);
+                    if (($property['type'] ?? 'string') === 'string') {
+                        $property['maxLength'] = $max;
+                    } else {
+                        $property['maximum'] = $max;
+                    }
-                } elseif ($rule === 'integer' || $rule === 'numeric') {
-                    $property['type'] = 'integer';
+                } elseif ($rule === 'integer') {
+                    $property['type'] = 'integer';
+                } elseif ($rule === 'numeric') {
+                    $property['type'] = 'number';

Required-ness is already handled in getRequiredFieldsFromRules, so just drop $isRequired entirely.

Right now consumers of your OpenAPI will be in “trust issues” mode when they hit numeric fields.

Also applies to: 564-579, 532-559


662-680: Dead “backward compatibility” helper that’s never been used

createMockFormRequest() doesn’t use its $formRequestClass parameter, isn’t called anywhere, and the “backward compatibility” comment is hilarious given this is a brand-new generator.

Delete it and let git be your time machine if you ever need it back.

-    /**
-     * Create a mock FormRequest instance without triggering validation.
-     */
-    private function createMockFormRequest(string $formRequestClass): \Illuminate\Foundation\Http\FormRequest
-    {
-        // This method is kept for backward compatibility but we now use extractRulesWithoutValidation
-        return new class extends \Illuminate\Foundation\Http\FormRequest
-        {
-            public function authorize(): bool
-            {
-                return true;
-            }
-
-            public function rules(): array
-            {
-                return [];
-            }
-        };
-    }
tests/Feature/ApiTest.php (5)

141-141: You're still asserting the token equals itself. Galaxy brain moment.

Line 141: 'token' => $response->json('data.token') - you're literally saying "the token should be whatever the token is." This passes for any non-null value. It's like testing expect(x).toBe(x). Either nuke it or actually validate it's a non-empty string.

     $response->assertOk()
         ->assertJson([
             'success' => true,
             'message' => 'Login successful',
             'data' => [
                 'user' => [
                     'id' => $user->id,
                     'name' => $user->name,
                     'email' => '[email protected]',
                 ],
-                'token' => $response->json('data.token'),
                 'token_type' => 'Bearer',
             ],
             'errors' => null,
-        ]);
+        ])
+        ->assertJsonPath('data.token', fn ($token) => is_string($token) && strlen($token) > 0);

567-576: Both logins get the same device name - your "multiple tokens" test isn't testing multiple distinct tokens.

You're logging in twice without specifying a device, so both get api-token-web. Your assertion at line 581-585 just verifies one exists. You have no proof there are actually two tokens with different identities.

     // Login multiple times to create multiple tokens
     $this->postJson('/api/v1/login', [
         'email' => '[email protected]',
         'password' => 'password123',
+        'device' => 'device1',
     ]);

     $loginResponse2 = $this->postJson('/api/v1/login', [
         'email' => '[email protected]',
         'password' => 'password123',
+        'device' => 'device2',
     ]);

846-849: URL parsing duplicated - extract a helper already.

You're doing the parse_url + parse_str dance twice (lines 846-849 and 921-923). Copy-paste is not DRY. Extract it:

function getSignedUrlParams(string $signedUrl): array
{
    $urlParts = parse_url($signedUrl);
    parse_str($urlParts['query'], $queryParams);
    return $queryParams;
}

Then: $params = getSignedUrlParams($signedUrl); and grab $params['signature'], $params['expires'].

Also applies to: 921-923


1074-1074: 'test-secret' is not a valid TOTP secret, and you keep using it.

Lines 1074, 1123, 1188, 1220 all use 'two_factor_secret' => 'test-secret'. This isn't a valid base32-encoded secret. These tests pass because you're only testing code retrieval, not OTP validation. Someone will copy this pattern for an OTP validation test and waste hours debugging.

Use a real base32 secret: 'JBSWY3DPEHPK3PXP' or generate one properly.


246-256: Same self-referential assertion disease spreading.

Lines 251-252: 'secret' => $response->json('data.secret') and 'qr_code_url' => $response->json('data.qr_code_url') - you're testing that values equal themselves. This tests nothing. The structure check at assertJsonStructure would be sufficient, or validate that secret matches a base32 pattern.

     $response->assertOk()
         ->assertJson([
             'success' => true,
             'message' => 'Two-factor authentication setup initiated.',
             'data' => [
-                'secret' => $response->json('data.secret'),
-                'qr_code_url' => $response->json('data.qr_code_url'),
                 'next_step' => 'Scan the QR code with your authenticator app and confirm with the generated code.',
             ],
             'errors' => null,
-        ]);
+        ])
+        ->assertJsonPath('data.secret', fn ($s) => is_string($s) && preg_match('/^[A-Z2-7]{16,}$/', $s))
+        ->assertJsonPath('data.qr_code_url', fn ($url) => str_starts_with($url, 'otpauth://'));
app/Http/Controllers/Api/Auth/TwoFactorController.php (1)

43-47: You're still shipping the raw TOTP secret in the response.

Line 44: 'secret' => $secret - this plaintext secret will end up in logs, network captures, and whoever else is watching. The QR code URL already contains the secret (it's in the otpauth:// URI), which is unavoidable for setup.

Most apps only return the QR code and have a "Can't scan? Show manual entry key" button that reveals the secret on demand via a separate endpoint. If you must return it here, at minimum document that this endpoint's response should NEVER be logged.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to Reviews > Disable Cache setting

📥 Commits

Reviewing files that changed from the base of the PR and between 3f780f6 and f36543b.

📒 Files selected for processing (24)
  • .github/workflows/tests.yml (3 hunks)
  • app/Console/Commands/GenerateOpenApiSpec.php (1 hunks)
  • app/Http/Controllers/Api/Auth/EmailVerificationController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/LoginController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/LogoutController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/ProfileController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/RegisterController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/TwoFactorController.php (1 hunks)
  • app/Http/Controllers/Api/Auth/UserController.php (1 hunks)
  • app/Http/Middleware/ApiThrottle.php (1 hunks)
  • app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/DeleteAccountRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1 hunks)
  • app/Http/Resources/UserResource.php (1 hunks)
  • app/Services/UserDeletionService.php (1 hunks)
  • app/Support/TimeFormatter.php (1 hunks)
  • storage/postman/api_collection (2 hunks)
  • tests/Feature/ApiTest.php (1 hunks)
  • tests/Unit/ExampleTest.php (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (16)
app/Http/Controllers/Api/Auth/ProfileController.php (4)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • success (12-23)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1)
  • UpdateProfileRequest (8-86)
app/Http/Resources/UserResource.php (1)
  • UserResource (8-24)
app/Models/User.php (1)
  • sendEmailVerificationNotification (66-69)
tests/Feature/ApiTest.php (1)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (2)
  • UpdateProfileRequest (8-86)
  • authorize (15-40)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (5)
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-31)
  • messages (38-46)
app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (3)
  • authorize (15-18)
  • rules (25-31)
  • messages (36-43)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (37-43)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (3)
  • authorize (15-40)
  • rules (47-66)
  • messages (73-85)
app/Http/Controllers/Api/Auth/EmailVerificationController.php (4)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • error (28-46)
  • success (12-23)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1)
  • VerifyEmailRequest (7-43)
app/Http/Resources/UserResource.php (1)
  • UserResource (8-24)
app/Models/User.php (2)
  • User (14-70)
  • sendEmailVerificationNotification (66-69)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (3)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (2)
  • authorize (15-18)
  • rules (25-30)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (2)
  • authorize (15-18)
  • rules (25-31)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (2)
  • authorize (12-15)
  • rules (22-27)
app/Http/Middleware/ApiThrottle.php (1)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • rateLimited (88-108)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (5)
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-31)
  • messages (38-46)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (3)
  • authorize (15-18)
  • rules (25-31)
  • messages (38-49)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (3)
  • authorize (12-15)
  • rules (22-27)
  • messages (34-40)
app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (5)
app/Console/Commands/GenerateOpenApiSpec.php (2)
  • authorize (670-673)
  • rules (675-678)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (37-43)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (3)
  • authorize (12-15)
  • rules (22-30)
  • messages (35-42)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (3)
  • authorize (12-15)
  • rules (22-27)
  • messages (34-40)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (3)
app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (3)
  • authorize (15-18)
  • rules (25-31)
  • messages (36-43)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (35-41)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (3)
  • authorize (15-18)
  • rules (25-30)
  • messages (37-43)
tests/Unit/ExampleTest.php (2)
app/Support/TimeFormatter.php (2)
  • TimeFormatter (5-69)
  • secondsToHuman (23-68)
app/Helpers/ApiResponse.php (1)
  • secondsToHuman (113-116)
app/Console/Commands/GenerateOpenApiSpec.php (11)
tests/Feature/ApiTest.php (1)
  • route (716-719)
app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (1)
  • rules (25-31)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1)
  • rules (25-30)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)
  • rules (25-30)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (1)
  • rules (25-31)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1)
  • rules (47-66)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1)
  • rules (22-30)
app/Http/Requests/Api/Auth/RegisterRequest.php (1)
  • rules (22-29)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (1)
  • rules (22-27)
app/Http/Requests/Api/Auth/LoginRequest.php (1)
  • rules (22-30)
app/Http/Requests/Api/Auth/ResetPasswordRequest.php (1)
  • rules (22-29)
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (4)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (2)
  • authorize (15-18)
  • rules (25-30)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (2)
  • authorize (15-18)
  • rules (25-30)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (2)
  • authorize (12-15)
  • rules (22-30)
app/Http/Requests/Api/Auth/ForgotPasswordRequest.php (2)
  • authorize (12-15)
  • rules (22-27)
app/Http/Controllers/Api/Auth/UserController.php (3)
app/Helpers/ApiResponse.php (2)
  • ApiResponse (7-117)
  • success (12-23)
app/Http/Resources/UserResource.php (1)
  • UserResource (8-24)
app/Http/Controllers/Api/Auth/LoginController.php (1)
  • __invoke (21-60)
app/Http/Controllers/Api/Auth/LogoutController.php (1)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • success (12-23)
  • error (28-46)
app/Http/Controllers/Api/Auth/TwoFactorController.php (5)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • error (28-46)
  • success (12-23)
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (1)
  • ConfirmTwoFactorRequest (7-47)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1)
  • DisableTwoFactorRequest (7-42)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)
  • EnableTwoFactorRequest (7-44)
tests/Feature/ApiTest.php (1)
  • user (702-705)
app/Support/TimeFormatter.php (1)
app/Helpers/ApiResponse.php (1)
  • secondsToHuman (113-116)
🪛 PHPMD (2.15.0)
app/Http/Controllers/Api/Auth/ProfileController.php

36-38: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'update'. (undefined)

(StaticAccess)


37-37: Avoid using static access to class '\App\Http\Resources\UserResource' in method 'update'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/EmailVerificationController.php

20-20: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'sendVerificationEmail'. (undefined)

(StaticAccess)


26-26: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'sendVerificationEmail'. (undefined)

(StaticAccess)


30-30: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'sendVerificationEmail'. (undefined)

(StaticAccess)


34-94: The method verifyEmail() has an NPath complexity of 256. The configured NPath complexity threshold is 200. (undefined)

(NPathComplexity)


36-36: Avoid using static access to class '\App\Models\User' in method 'verifyEmail'. (undefined)

(StaticAccess)


39-39: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


44-44: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


52-52: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


60-60: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


78-82: Avoid using static access to class '\Illuminate\Http\Request' in method 'verifyEmail'. (undefined)

(StaticAccess)


85-85: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


91-93: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


92-92: Avoid using static access to class '\App\Http\Resources\UserResource' in method 'verifyEmail'. (undefined)

(StaticAccess)

app/Http/Middleware/ApiThrottle.php

42-47: The method handle uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


56-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'handle'. (undefined)

(StaticAccess)

app/Services/UserDeletionService.php

32-95: Avoid using static access to class '\Illuminate\Support\Facades\DB' in method 'deleteUser'. (undefined)

(StaticAccess)


35-41: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'deleteUser'. (undefined)

(StaticAccess)


56-56: Avoid using static access to class '\Illuminate\Support\Facades\Schema' in method 'deleteUser'. (undefined)

(StaticAccess)


75-79: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'deleteUser'. (undefined)

(StaticAccess)


85-90: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'deleteUser'. (undefined)

(StaticAccess)


108-118: Avoid using static access to class '\Illuminate\Support\Facades\DB' in method 'softDeleteUser'. (undefined)

(StaticAccess)


120-124: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'softDeleteUser'. (undefined)

(StaticAccess)


129-133: Avoid using static access to class '\Illuminate\Support\Facades\Log' in method 'softDeleteUser'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/LoginController.php

29-29: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method '__invoke'. (undefined)

(StaticAccess)


30-32: Avoid using static access to class '\Illuminate\Validation\ValidationException' in method '__invoke'. (undefined)

(StaticAccess)


37-39: Avoid using static access to class '\Illuminate\Validation\ValidationException' in method '__invoke'. (undefined)

(StaticAccess)


43-43: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method '__invoke'. (undefined)

(StaticAccess)


44-44: Avoid using static access to class '\Illuminate\Support\Facades\Hash' in method '__invoke'. (undefined)

(StaticAccess)


55-59: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)


56-56: Avoid using static access to class '\App\Http\Resources\UserResource' in method '__invoke'. (undefined)

(StaticAccess)

app/Console/Commands/GenerateOpenApiSpec.php

13-734: The class GenerateOpenApiSpec has an overall complexity of 99 which is very high. The configured complexity threshold is 50. (undefined)

(ExcessiveClassComplexity)


38-38: Avoid using static access to class '\Symfony\Component\Yaml\Yaml' in method 'handle'. (undefined)

(StaticAccess)


74-74: Avoid using static access to class '\Illuminate\Support\Facades\Route' in method 'getApiRoutes'. (undefined)

(StaticAccess)


258-260: The method getSummaryFromController uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


413-413: Avoid using static access to class '\Illuminate\Support\Facades\Route' in method 'getControllerForUri'. (undefined)

(StaticAccess)


487-527: The method convertFieldRulesToOpenApiProperty() has a Cyclomatic Complexity of 12. The configured cyclomatic complexity threshold is 10. (undefined)

(CyclomaticComplexity)


493-493: Avoid unused local variables such as '$isRequired'. (undefined)

(UnusedLocalVariable)


496-496: Avoid unused local variables such as '$minLength'. (undefined)

(UnusedLocalVariable)


497-497: Avoid unused local variables such as '$maxLength'. (undefined)

(UnusedLocalVariable)


608-611: The method getRequestProperties uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


643-643: Avoid using static access to class '\Illuminate\Http\Request' in method 'extractRulesWithoutValidation'. (undefined)

(StaticAccess)


665-680: Avoid unused private methods such as 'createMockFormRequest'. (undefined)

(UnusedPrivateMethod)


665-665: Avoid unused parameters such as '$formRequestClass'. (undefined)

(UnusedFormalParameter)


709-712: The method getRequiredFields uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)

app/Http/Resources/UserResource.php

15-15: Avoid unused parameters such as '$request'. (undefined)

(UnusedFormalParameter)

app/Http/Controllers/Api/Auth/UserController.php

17-19: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)


18-18: Avoid using static access to class '\App\Http\Resources\UserResource' in method '__invoke'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/LogoutController.php

13-122: The method logout() has 110 lines of code. Current threshold is set to 100. Avoid really long methods. (undefined)

(ExcessiveMethodLength)


26-26: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


29-33: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


35-40: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


42-78: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


48-48: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


50-54: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


56-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


63-77: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


65-69: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


71-76: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


79-87: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


81-86: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


99-99: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


101-104: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


106-111: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


116-121: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


128-133: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)


140-140: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)


142-145: Avoid using static access to class '\Log' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)


147-152: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logoutFromAllDevices'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/TwoFactorController.php

24-24: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'enable'. (undefined)

(StaticAccess)


43-47: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'enable'. (undefined)

(StaticAccess)


58-58: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


61-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


69-69: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


81-84: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


93-93: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


100-100: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


104-104: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


109-109: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


112-115: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


124-124: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'regenerateRecoveryCodes'. (undefined)

(StaticAccess)


135-138: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'regenerateRecoveryCodes'. (undefined)

(StaticAccess)


147-147: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'disable'. (undefined)

(StaticAccess)


157-157: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'disable'. (undefined)

(StaticAccess)

app/Http/Controllers/Api/Auth/RegisterController.php

29-33: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)


30-30: Avoid using static access to class '\App\Http\Resources\UserResource' in method '__invoke'. (undefined)

(StaticAccess)


51-51: Avoid using static access to class '\App\Helpers\ApiResponse' in method '__invoke'. (undefined)

(StaticAccess)

🔇 Additional comments (33)
storage/postman/api_collection (6)

12-48: LGTM!

Login endpoint is properly structured with the right method, headers, and payload fields.


89-127: LGTM!

Forgot-password endpoint correctly requires only the email field.


395-477: Session management endpoints look solid.

GET for listing sessions, DELETE for revoking a specific session with proper {sessionId} path param. Auth is wired up correctly. No complaints.


479-709: 2FA flow coverage is comprehensive.

GET recovery codes, POST regenerate, POST enable, PATCH confirm, DELETE disable - all the operations are there with proper auth. This is how you do a 2FA API surface.


854-939: Logout endpoints properly structured.

Both single-device logout and logout-all require bearer auth as expected. Clean.


943-964: Collection auth and variables well configured.

Having auth_token as a collection variable with collection-level bearer auth as fallback is the right pattern. Individual endpoints can override when needed. Good DX for whoever's actually using this collection.

app/Http/Controllers/Api/Auth/UserController.php (1)

13-20: LGTM. Clean fix for the previous raw user exposure.

You wrapped the user in UserResource, which explicitly projects only the safe fields. No more accidental leakage of 2FA secrets or recovery codes. This is the right pattern.

app/Http/Requests/Api/Auth/DeactivateAccountRequest.php (1)

15-18: Both previous issues addressed correctly.

You fixed the password validation (added current_password:sanctum) and simplified authorization (now just return true trusting route middleware). Clean.

Also applies to: 27-30

app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)

15-18: Validation is correct.

You're using current_password:sanctum which explicitly tells Laravel to check against the sanctum guard. The auth()->check() in authorize() is redundant since the route middleware already enforces it, but it's harmless defense-in-depth.

Also applies to: 27-29

app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1)

15-18: Field naming is consistent, validation is correct.

This request uses current_password consistently with the other account management endpoints, and correctly validates against the sanctum guard.

Also applies to: 27-29

app/Support/TimeFormatter.php (1)

23-68: Solid implementation despite the linter crying.

The logic is clean: break down time units, skip zeros, format with Oxford comma. PHPMD whines about cyclomatic complexity, but you already extracted formatTimeUnit() to keep it readable. The code is easy to follow and handles all edge cases correctly.

Only microscopic nitpick: floor() returns float, but you're using it as int in arithmetic. PHP coerces it fine, but if you want to make static analysis shut up:

-        $days = floor($seconds / 86400);
+        $days = (int) floor($seconds / 86400);

Same for $hours and $minutes. But honestly? Ship it as-is.

app/Http/Requests/Api/Auth/DeleteAccountRequest.php (1)

15-18: DeleteAccountRequest now properly enforces password verification

This finally uses current_password:sanctum and an auth check, matching your other sensitive flows. No correctness gaps spotted here.

Also applies to: 25-30

app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1)

22-29: VerifyEmailRequest is lean and consistent with the controller flow

Validation matches how EmailVerificationController actually uses the fields, and the earlier bogus id.exists message is gone. Nothing to fix here.

Also applies to: 35-41

app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (1)

15-18: UpdatePasswordRequest closes the previous validation holes

Guard-qualified current_password:sanctum plus a sane max length and “different from current” is exactly what you want here. This request layer looks solid.

Also applies to: 25-30, 38-48

app/Http/Controllers/Api/Auth/RegisterController.php (1)

15-52: Registration flow is clean, token naming + logging look sane

Nice cleanup: reuse CreateNewUser, generate a device‑tagged token name, and log only allow‑listed fields with stack traces gated by app.debug. This is how you do an auth endpoint without turning logs into a data breach.

app/Http/Controllers/Api/Auth/ProfileController.php (1)

13-38: Profile update finally stops flirting with “store current_password in DB”

Good fix: you now whitelist updatable fields and keep current_password out of the mass-assignment path, while still handling email-change verification correctly and using UserResource for the response. This is the right level of paranoia for a profile endpoint.

app/Http/Middleware/ApiThrottle.php (1)

25-65: Clean implementation - solid work after the refactor.

The middleware now:

  • Properly uses null coalescing for config fallbacks (route params take precedence)
  • Delegates actual throttling to parent
  • Returns standardized ApiResponse::rateLimited() for 429 responses

The static access warnings from PHPMD are false positives - this is the established pattern in this codebase for helper classes. The else expression is readable and appropriate here.

app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1)

47-66: Validation rules look solid.

Good defensive implementation:

  • Email uniqueness properly ignores current user
  • current_password conditionally required only when email changes (security win)
  • Uses current_password:sanctum guard for proper Sanctum token validation
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (1)

7-47: Clean FormRequest - follows the established pattern.

Consistent with sibling request classes. The digits:6 rule ensures exactly 6 numeric characters, and requiring current_password before enabling 2FA is proper security hygiene. The docblock reminder about auth:sanctum middleware is a nice touch for defense in depth.

app/Http/Controllers/Api/Auth/LoginController.php (1)

21-60: Login flow is solid after the refactor.

Previous issues addressed:

  • No longer using stateful auth('web')->attempt()
  • Deactivation check blocks deactivated users from getting tokens
  • Password rehash on login keeps hashes fresh

The static access warnings are false positives - Hash, ValidationException, and ApiResponse are standard Laravel/project patterns.

app/Http/Controllers/Api/Auth/LogoutController.php (1)

124-154: logoutFromAllDevices is clean.

Properly requires token auth, wraps the bulk delete in try-catch, and returns appropriate error codes. The bearer token check addresses the past review concern about inconsistency with logout().

tests/Feature/ApiTest.php (7)

9-35: LGTM! Clean auth guard tests.

Basic authentication checks are solid - you're testing both the negative (unauthenticated) and positive (authenticated) paths. Structure assertions are appropriate.


37-118: Registration tests are comprehensive.

Good coverage: happy path, validation errors, and duplicate email handling. The 'token' => true assertion pattern (line 56) correctly verifies token presence without the self-referential assertion anti-pattern.


156-197: Multi-device token test done right.

Unlike certain other tests in this file, you actually specify different device names here. Both tokens are properly verified with distinct names. Good job.


1474-1482: Good fix on the token verification.

You're now querying PersonalAccessToken directly instead of calling $user->tokens() on a deleted model. That past comment was addressed properly.


682-775: Type juggling authorization test is thorough.

Good security-conscious testing. You're validating that string "2" and int 2 are both rejected when they don't match the authenticated user, and that string "1" and int 1 are both accepted when they do match. The edge case with ID=0 is a nice touch - PHP's loose comparisons with zero are notorious footguns.


463-528: Password update tests are solid.

Testing both success and failure paths with proper credential validation. Good coverage.


1275-1450: Account lifecycle tests (deactivate/reactivate) are comprehensive.

You're testing:

  • Successful deactivation with token revocation
  • Wrong password rejection
  • Already deactivated account handling
  • Reactivation flows
  • State verification after each operation

This is the level of coverage these sensitive operations need.

app/Http/Controllers/Api/Auth/TwoFactorController.php (5)

50-85: Confirm flow is solid.

Good defensive coding:

  • DecryptException handling for corrupted secrets
  • Verification window of 1 allows reasonable clock drift (~30s each way)
  • Recovery codes generated atomically with confirmation

One thing: verifyKey with window=1 means codes from -30s to +30s are valid. That's industry standard.


87-116: Recovery codes retrieval looks good.

The json_decode error handling is correct now - checking json_last_error() instead of wrapping in a useless try-catch. DecryptException handling is appropriate since the model cast could fail on corrupted data.


141-158: Disable method now properly requires password verification.

Using DisableTwoFactorRequest addresses the past security concern. All three 2FA fields are properly nulled out.


160-167: Recovery code generation looks good.

9 codes with 64 bits of entropy each (two 4-byte random hex segments). The XXXX-XXXX format is much more human-readable than the raw 10-char hex that was suggested previously. Total entropy of ~576 bits for the recovery set is plenty.


1-15: PHPMD warnings about static access are noise - ignore them.

ApiResponse::success() and ApiResponse::error() are static helpers by design. This is standard Laravel practice for utility classes. PHPMD flagging every static call is like a linter complaining you're using Math.max() in JavaScript.

Comment on lines +34 to +90
public function verifyEmail(VerifyEmailRequest $request): JsonResponse
{
$user = User::find($request->id);

if (! $user) {
return ApiResponse::error('User not found.', null, 404, 'USER_NOT_FOUND');
}

// Check if email is already verified
if ($user->hasVerifiedEmail()) {
return ApiResponse::error('Email is already verified.', null, 400, 'EMAIL_ALREADY_VERIFIED');
}

// Verify the hash matches the user's email
if (! hash_equals(
sha1((string) $user->getEmailForVerification()),
(string) $request->hash
)) {
return ApiResponse::error('Invalid verification link.', null, 400, 'INVALID_VERIFICATION_LINK');
}

// CRITICAL SECURITY: Require signed request to prevent unauthorized verification
// The request must include a valid signature to prove it came from a legitimate verification link
$signature = $request->get('signature') ?: $request->query('signature');

if (! $signature) {
return ApiResponse::error('Verification signature required.', null, 400, 'SIGNATURE_REQUIRED');
}

// Validate the signature by creating a GET request with the same parameters
$queryParams = $request->query();

// Ensure we have the required parameters for signature validation
// Only populate from request body if absent from query parameters
if (empty($queryParams['id'])) {
$queryParams['id'] = $request->id;
}
if (empty($queryParams['hash'])) {
$queryParams['hash'] = $request->hash;
}

// Sort parameters alphabetically as required for signature validation
ksort($queryParams);

$getRequest = \Illuminate\Http\Request::create(
route('api.v1.email.verify'),
'GET',
$queryParams
);

if (! $getRequest->hasValidSignature()) {
return ApiResponse::error('Invalid or expired verification link.', null, 400, 'EXPIRED_VERIFICATION_LINK');
}

// Mark email as verified
$user->markEmailAsVerified();

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

verifyEmail does the right security checks, but it’s turning into a “god method”

The flow (user lookup, hash check, signature validation, then mark verified) is solid and avoids the earlier signature bugs. But this method is doing everything itself, which is why PHPMD is screaming about NPath=256.

Consider extracting helpers like resolveVerifiableUser(), assertValidVerificationHash(), and assertValidSignature() to keep this thing from becoming the next AuthService::doEverything(). Behavior can stay identical; just push the branches into private methods.

🧰 Tools
🪛 PHPMD (2.15.0)

34-94: The method verifyEmail() has an NPath complexity of 256. The configured NPath complexity threshold is 200. (undefined)

(NPathComplexity)


36-36: Avoid using static access to class '\App\Models\User' in method 'verifyEmail'. (undefined)

(StaticAccess)


39-39: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


44-44: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


52-52: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


60-60: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)


78-82: Avoid using static access to class '\Illuminate\Http\Request' in method 'verifyEmail'. (undefined)

(StaticAccess)


85-85: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'verifyEmail'. (undefined)

(StaticAccess)

🤖 Prompt for AI Agents
In app/Http/Controllers/Api/Auth/EmailVerificationController.php around lines 34
to 90, this verifyEmail method is too large and triggers PHPMD NPath complexity;
extract the logical blocks into private helper methods: create private
resolveVerifiableUser(VerifyEmailRequest $request): User|null to encapsulate
user lookup and the "User not found" response logic (or throw an exception you
handle), assertValidVerificationHash(User $user, string $hash): void to
encapsulate the sha1/ hash_equals validation and throw or return a standardized
error, and assertValidSignature(VerifyEmailRequest $request): void to
encapsulate signature extraction, query param population, request creation and
hasValidSignature() check (again throw or return standardized error); refactor
verifyEmail to call these helpers in order and keep the original responses and
behavior unchanged.

Comment on lines +92 to +94
// Final fallback if sanitization made it empty
return $sanitized === '' || $sanitized === '0' ? 'web' : $sanitized;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

What's with the '0' check?

After trim($sanitized, '-_'), if the user sent just -_-_-, you get ''. But when would you get '0'? If the input was literally "0", which is... a valid device name? This check would reject it and return 'web' instead.

If '0' is intentionally blocked, add a comment explaining why. Otherwise, this looks like a copy-paste artifact:

-        return $sanitized === '' || $sanitized === '0' ? 'web' : $sanitized;
+        return $sanitized === '' ? 'web' : $sanitized;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Final fallback if sanitization made it empty
return $sanitized === '' || $sanitized === '0' ? 'web' : $sanitized;
}
// Final fallback if sanitization made it empty
return $sanitized === '' ? 'web' : $sanitized;
}
🤖 Prompt for AI Agents
In app/Http/Controllers/Api/Auth/LoginController.php around lines 92 to 94, the
ternary check treats the string '0' as equivalent to empty and returns 'web';
this will incorrectly reject a literal device name "0". Either remove the "'0'
||" part from the condition so only empty strings fall back to 'web', or if '0'
must be prohibited, keep the check and add a clear inline comment explaining why
"0" is invalid; update tests if any to cover the "0" input behavior.

Comment on lines +13 to +122
public function logout(Request $request): JsonResponse
{
// Check if this is token-based authentication (Sanctum)
if ($request->bearerToken()) {
// Token-based authentication: delete the current token
$token = $request->user()->currentAccessToken();

if ($token) {
// Validate token type and attempt deletion
if ($token instanceof \Laravel\Sanctum\PersonalAccessToken) {
try {
$token->delete();

return ApiResponse::success(null, 'Successfully logged out from this device.');
} catch (\Exception $e) {
// Log the error but don't expose internal details
\Log::error('Failed to delete personal access token', [
'token_id' => $token->id,
'user_id' => $request->user()->id,
'error' => $e->getMessage(),
]);

return ApiResponse::error(
'Failed to revoke token. Please try again.',
null,
500,
'TOKEN_REVOKE_FAILED'
);
}
} else {
// Token exists but is not a PersonalAccessToken - check if revoke method exists
if (method_exists($token, 'revoke')) {
try {
$token->revoke();

return ApiResponse::success(null, 'Successfully logged out from this device.');
} catch (\Exception $e) {
\Log::error('Failed to revoke non-personal access token', [
'token_type' => $token::class,
'user_id' => $request->user()->id,
'error' => $e->getMessage(),
]);

return ApiResponse::error(
'Unable to revoke authentication token.',
null,
403,
'TOKEN_REVOKE_UNSUPPORTED'
);
}
} else {
// Token type does not support revoke method
\Log::warning('Attempted to revoke token without revoke method', [
'token_type' => $token::class,
'user_id' => $request->user()->id,
'token_id' => $token->id ?? 'unknown',
]);

return ApiResponse::error(
'Unable to revoke authentication token.',
null,
403,
'TOKEN_REVOKE_UNSUPPORTED'
);
}
}
} else {
// Token was provided in request but not found in database
return ApiResponse::error(
'Invalid or expired authentication token.',
null,
401,
'INVALID_TOKEN'
);
}
}

// Check if this is session-based authentication (web guard)
if (Auth::guard('web')->check()) {
try {
Auth::guard('web')->logout();

// Invalidate the session and regenerate CSRF token
$request->session()->invalidate();
$request->session()->regenerateToken();

return ApiResponse::success(null, 'Successfully logged out from this device.');
} catch (\Exception $e) {
\Log::error('Failed to logout from web session', [
'user_id' => $request->user()->id ?? 'unknown',
'error' => $e->getMessage(),
]);

return ApiResponse::error(
'Failed to logout. Please try again.',
null,
500,
'SESSION_LOGOUT_FAILED'
);
}
}

// No valid authentication method found
return ApiResponse::error(
'No valid authentication found. Please authenticate first.',
null,
401,
'NO_AUTHENTICATION'
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

This method is 110 lines of nested if-else spaghetti.

I get it - you're handling token auth, session auth, different token types, and error cases. But this is the kind of method where a bug can hide for months. PHPMD isn't wrong to flag it.

Extract the token handling into a private method:

 public function logout(Request $request): JsonResponse
 {
     // Check if this is token-based authentication (Sanctum)
     if ($request->bearerToken()) {
-        // Token-based authentication: delete the current token
-        $token = $request->user()->currentAccessToken();
-        // ... 70 lines of token handling ...
+        return $this->handleTokenLogout($request);
     }

     // Check if this is session-based authentication (web guard)
     if (Auth::guard('web')->check()) {
-        try {
-            // ... session handling ...
-        } catch (\Exception $e) {
-            // ... error handling ...
-        }
+        return $this->handleSessionLogout($request);
     }

     // No valid authentication method found
     return ApiResponse::error(
         'No valid authentication found. Please authenticate first.',
         null,
         401,
         'NO_AUTHENTICATION'
     );
 }
+
+private function handleTokenLogout(Request $request): JsonResponse
+{
+    $token = $request->user()->currentAccessToken();
+    // ... token handling logic ...
+}
+
+private function handleSessionLogout(Request $request): JsonResponse
+{
+    // ... session handling logic ...
+}

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 PHPMD (2.15.0)

13-122: The method logout() has 110 lines of code. Current threshold is set to 100. Avoid really long methods. (undefined)

(ExcessiveMethodLength)


26-26: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


29-33: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


35-40: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


42-78: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


48-48: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


50-54: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


56-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


63-77: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


65-69: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


71-76: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


79-87: The method logout uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)


81-86: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


99-99: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


101-104: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


106-111: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


116-121: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)

Comment on lines +56 to +76
return ApiResponse::error(
'Unable to revoke authentication token.',
null,
403,
'TOKEN_REVOKE_UNSUPPORTED'
);
}
} else {
// Token type does not support revoke method
\Log::warning('Attempted to revoke token without revoke method', [
'token_type' => $token::class,
'user_id' => $request->user()->id,
'token_id' => $token->id ?? 'unknown',
]);

return ApiResponse::error(
'Unable to revoke authentication token.',
null,
403,
'TOKEN_REVOKE_UNSUPPORTED'
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

DRY violation - same error response duplicated.

Lines 56-61 and 71-76 return identical TOKEN_REVOKE_UNSUPPORTED responses. The only difference is the log message (one says "revoke" failed, one says "no revoke method"). Consolidate:

-                } else {
-                    // Token exists but is not a PersonalAccessToken - check if revoke method exists
-                    if (method_exists($token, 'revoke')) {
-                        try {
-                            $token->revoke();
-
-                            return ApiResponse::success(null, 'Successfully logged out from this device.');
-                        } catch (\Exception $e) {
-                            \Log::error('Failed to revoke non-personal access token', [
-                                'token_type' => $token::class,
-                                'user_id' => $request->user()->id,
-                                'error' => $e->getMessage(),
-                            ]);
-
-                            return ApiResponse::error(
-                                'Unable to revoke authentication token.',
-                                null,
-                                403,
-                                'TOKEN_REVOKE_UNSUPPORTED'
-                            );
-                        }
-                    } else {
-                        // Token type does not support revoke method
-                        \Log::warning('Attempted to revoke token without revoke method', [
-                            'token_type' => $token::class,
-                            'user_id' => $request->user()->id,
-                            'token_id' => $token->id ?? 'unknown',
-                        ]);
-
-                        return ApiResponse::error(
-                            'Unable to revoke authentication token.',
-                            null,
-                            403,
-                            'TOKEN_REVOKE_UNSUPPORTED'
-                        );
-                    }
-                }
+                } elseif (method_exists($token, 'revoke')) {
+                    try {
+                        $token->revoke();
+                        return ApiResponse::success(null, 'Successfully logged out from this device.');
+                    } catch (\Exception $e) {
+                        // Fall through to unsupported response
+                    }
+                }
+
+                // Token type not supported or revoke failed
+                \Log::warning('Unable to revoke token', [
+                    'token_type' => $token::class,
+                    'user_id' => $request->user()->id,
+                    'token_id' => $token->id ?? 'unknown',
+                ]);
+
+                return ApiResponse::error(
+                    'Unable to revoke authentication token.',
+                    null,
+                    403,
+                    'TOKEN_REVOKE_UNSUPPORTED'
+                );

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 PHPMD (2.15.0)

56-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)


65-69: Avoid using static access to class '\Log' in method 'logout'. (undefined)

(StaticAccess)


71-76: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'logout'. (undefined)

(StaticAccess)

🤖 Prompt for AI Agents
In app/Http/Controllers/Api/Auth/LogoutController.php around lines 56-76, the
same ApiResponse::error for 'TOKEN_REVOKE_UNSUPPORTED' is duplicated in two
branches; consolidate by returning the error once after handling both cases and
keep distinct logging within each branch. Update the control flow so each branch
only logs its specific warning (failed revoke vs. missing revoke method) and
then fall through to a single shared return of the ApiResponse::error('Unable to
revoke authentication token.', null, 403, 'TOKEN_REVOKE_UNSUPPORTED') placed
after the conditional blocks.

Comment on lines +9 to +24
test('time formatter converts seconds correctly', function (): void {
// Test seconds
expect(TimeFormatter::secondsToHuman(30))->toBe('30 seconds');
expect(TimeFormatter::secondsToHuman(1))->toBe('1 second');

// Test minutes and seconds
expect(TimeFormatter::secondsToHuman(90))->toBe('1 minute and 30 seconds');
expect(TimeFormatter::secondsToHuman(60))->toBe('1 minute');

// Test hours, minutes, seconds
expect(TimeFormatter::secondsToHuman(3661))->toBe('1 hour, 1 minute, and 1 second');

// Test days
expect(TimeFormatter::secondsToHuman(86400))->toBe('1 day');
expect(TimeFormatter::secondsToHuman(172800))->toBe('2 days');
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Tests cover the happy path, but where are the edge cases?

You test seconds, minutes, hours, and days. You test pluralization. You test Oxford comma. That's good. But what about:

  • 0 seconds (should return "0 seconds")
  • Negative input (should throw or handle gracefully)
  • Mixed zero values (e.g., 3600 seconds = "1 hour" not "1 hour and 0 minutes")
  • Large numbers (e.g., 31536000 = "365 days" - does it handle years?)

Your current implementation probably handles zeros correctly (they're skipped), but test it to prove it.

// Add edge case tests
expect(TimeFormatter::secondsToHuman(0))->toBe('0 seconds');
expect(TimeFormatter::secondsToHuman(3600))->toBe('1 hour'); // No minutes
expect(TimeFormatter::secondsToHuman(3601))->toBe('1 hour and 1 second'); // No minutes
🤖 Prompt for AI Agents
In tests/Unit/ExampleTest.php around lines 9–24, add edge-case assertions:
assert secondsToHuman(0) returns "0 seconds"; assert secondsToHuman(3600)
returns "1 hour" (no minutes shown); assert secondsToHuman(3601) returns "1 hour
and 1 second" (no zero minutes); assert secondsToHuman(-1) throws an
InvalidArgumentException (or the project's chosen error type) to guard against
negative input; and assert secondsToHuman(31536000) returns "365 days" (or
adjust to "1 year" if the implementation documents year support) so large-number
behavior is covered.

@julian-richter
Copy link
Member Author

📚 Automated Documentation Summary

Pull Request: das-22-implement-missing-api-routes-author-openapi-specification

This PR adds comprehensive API controllers, request throttling, an OpenAPI spec generator, standardized JSON responses, and missing middleware. It also updates environment settings and developer guidelines to support these features.


Environment Configuration

New .env variables control API rate limits to prevent abuse.

Variable Default Description
API_THROTTLE_MAX_ATTEMPTS 100 Max requests/min for general endpoints
API_THROTTLE_DECAY_MINUTES 1 Reset period in minutes
API_THROTTLE_AUTH_MAX_ATTEMPTS 5 Max auth attempts/min (login/register)
API_THROTTLE_AUTH_DECAY_MINUTES 1 Reset period in minutes for auth
API_THROTTLE_PASSWORD_RESET_MAX_ATTEMPTS 3 Max password‐reset requests per period
API_THROTTLE_PASSWORD_RESET_DECAY_MINUTES 15 Reset period for password‐reset
# Example
API_THROTTLE_MAX_ATTEMPTS=100
API_THROTTLE_DECAY_MINUTES=1

Developer Tools Update

The following configuration and agent files now include rector/rector (RECTOR) - v2 as a code‐refactoring tool:

  • .junie/guidelines.md
  • AGENTS.md
  • CLAUDE.md
  • GEMINI.md

Action: CreateNewUser Simplification

Removed manual validation. Now relies on Fortify’s built‐in rules:

// Before: explicit Validator::make() calls
// After: direct User::create(), using PasswordValidationRules trait
return User::create([
    'name'     => $input['name'],
    'email'    => $input['email'],
    'password' => bcrypt($input['password']),
]);

Command: GenerateOpenApiSpec 📜

Generates a YAML OpenAPI 3.0.3 spec by scanning api.v1.* routes.

  • Filters routes by name, middleware, or URI prefix.
  • Reflects controllers and FormRequests for summaries, request bodies, and schemas.
  • Writes storage/openapi.yaml (configurable via --output).
php artisan openapi:generate --output=docs/openapi.yaml

Helper: ApiResponse 🎨

Standardizes JSON responses across all APIs.

use App\Helpers\ApiResponse;

// Success
return ApiResponse::success($data, 'Operation successful', 200);

// Error
return ApiResponse::error('Not found', null, 404, 'NOT_FOUND');

// Validation
return ApiResponse::validationError(['email' => 'Invalid'], 'Validation failed');

Middleware: ApiThrottle ⏱️

Extends ThrottleRequests to apply dynamic limits:

  • Auth endpoints: auth.max_attempts/auth.decay_minutes
  • Password reset: password_reset.*
  • Default: default.*

On 429, returns standardized rate-limit JSON:

{
  "success": false,
  "message": "Too many requests. Please try again later.",
  "error_code": "RATE_LIMIT_EXCEEDED",
  "data": {
    "retry_after_seconds": 60,
    "retry_after_human": "1 minute"
  }
}

API Endpoints

Base URL: https://api.example.com
All endpoints use Bearer token header unless noted.

Deactivate Account

{
  "title": "Deactivate Account",
  "description": "Deactivate the authenticated user account and revoke sessions.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/deactivate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\",\n  \"reason\": \"No longer needed\"\n}",
  "responses": {
    "200": {
      "description": "Account has been deactivated successfully.",
      "body": "{\n  \"success\": true,\n  \"message\": \"Account has been deactivated successfully. All sessions have been terminated.\",\n  \"data\": null,\n  \"errors\": null\n}"
    },
    "400": {
      "description": "Account already deactivated or wrong password.",
      "body": "{ \"success\": false, \"message\": \"Account is already deactivated.\", \"error_code\": \"ACCOUNT_ALREADY_DEACTIVATED\" }"
    }
  }
}

Reactivate Account

{
  "title": "Reactivate Account",
  "description": "Reactivate a previously deactivated user account.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/reactivate",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Account reactivated successfully.",
      "body": "{ \"success\": true, \"message\": \"Account has been reactivated successfully.\", \"data\": null }"
    },
    "400": {
      "description": "Already active or invalid password.",
      "body": "{ \"success\": false, \"message\": \"Account is already active.\", \"error_code\": \"ACCOUNT_ALREADY_ACTIVE\" }"
    }
  }
}

Delete Account

{
  "title": "Delete Account",
  "description": "Permanently delete the authenticated user account with GDPR cleanup.",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "Account has been deleted successfully.",
      "body": "{ \"success\": true, \"message\": \"Account has been deleted successfully.\" }"
    },
    "500": {
      "description": "Deletion failed.",
      "body": "{ \"success\": false, \"message\": \"Account deletion could not be completed. Please try again later.\", \"error_code\": \"ACCOUNT_DELETION_FAILED\" }"
    }
  }
}

Send Verification Email

{
  "title": "Send Verification Email",
  "description": "Send a new email verification link to the authenticated user.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verification-notification",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Link sent successfully.",
      "body": "{ \"success\": true, \"message\": \"Email verification link sent successfully.\" }"
    },
    "400": {
      "description": "Already verified.",
      "body": "{ \"success\": false, \"message\": \"Email is already verified.\", \"error_code\": \"EMAIL_ALREADY_VERIFIED\" }"
    }
  }
}

Verify Email

{
  "title": "Verify Email",
  "description": "Verify user email using signed link parameters.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/email/verify?id=1&hash=abc&signature=xyz",
  "headers": [],
  "pathParams": [
    { "key": "id", "value": "User ID", "required": true },
    { "key": "hash", "value": "Email hash", "required": true },
    { "key": "signature", "value": "Signed signature", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Email verified successfully.",
      "body": "{ \"success\": true, \"message\": \"Email verified successfully.\", \"data\": { \"user\": { /* user object */ } } }"
    },
    "400": {
      "description": "Invalid or expired link.",
      "body": "{ \"success\": false, \"message\": \"Invalid or expired verification link.\", \"error_code\": \"EXPIRED_VERIFICATION_LINK\" }"
    }
  }
}

Login

{
  "title": "User Login",
  "description": "Authenticate user and return Sanctum token.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/login",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\",\n  \"device\": \"web\"\n}",
  "responses": {
    "200": {
      "description": "Login successful.",
      "body": "{ \"success\": true, \"message\": \"Login successful\", \"data\": { \"user\": {/*...*/}, \"token\": \"<token>\", \"token_type\": \"Bearer\" } }"
    },
    "422": {
      "description": "Invalid credentials.",
      "body": "{ \"message\": \"The provided credentials are incorrect.\" }"
    }
  }
}

Logout

{
  "title": "Logout",
  "description": "Revoke current token or invalidate session.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Logged out successfully.",
      "body": "{ \"success\": true, \"message\": \"Successfully logged out from this device.\" }"
    },
    "401": {
      "description": "Invalid token.",
      "body": "{ \"success\": false, \"message\": \"Invalid or expired authentication token.\", \"error_code\": \"INVALID_TOKEN\" }"
    }
  }
}

Logout All Devices

{
  "title": "Logout All Devices",
  "description": "Revoke all Sanctum tokens for the user.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/logout-all",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "All sessions revoked.",
      "body": "{ \"success\": true, \"message\": \"Successfully logged out from all devices.\" }"
    }
  }
}

Forgot Password

{
  "title": "Forgot Password",
  "description": "Send password reset link to email.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/forgot-password",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\"\n}",
  "responses": {
    "200": {
      "description": "Reset link sent.",
      "body": "{ \"success\": true, \"message\": \"Password reset link sent to your email address.\" }"
    },
    "422": {
      "description": "Validation failed.",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": {/*...*/}, \"error_code\": \"VALIDATION_ERROR\" }"
    }
  }
}

Reset Password

{
  "title": "Reset Password",
  "description": "Reset user password using token.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/reset-password",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"email\": \"[email protected]\",\n  \"token\": \"reset-token\",\n  \"password\": \"newPass123\",\n  \"password_confirmation\": \"newPass123\"\n}",
  "responses": {
    "200": {
      "description": "Password reset.",
      "body": "{ \"success\": true, \"message\": \"Password has been reset successfully.\" }"
    },
    "422": {
      "description": "Invalid token or validation error.",
      "body": "{ \"success\": false, \"message\": \"Validation failed\", \"errors\": {/*...*/}, \"error_code\": \"VALIDATION_ERROR\" }"
    }
  }
}

Update Password

{
  "title": "Update Password",
  "description": "Change password for authenticated user.",
  "method": "PUT",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/password",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"oldPass123\",\n  \"password\": \"newPass123\",\n  \"password_confirmation\": \"newPass123\"\n}",
  "responses": {
    "200": {
      "description": "Password updated.",
      "body": "{ \"success\": true, \"message\": \"Password updated successfully.\" }"
    }
  }
}

Update Profile

{
  "title": "Update Profile",
  "description": "Update name or email of authenticated user.",
  "method": "PUT",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/profile",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"Jane Doe\",\n  \"email\": \"[email protected]\"\n}",
  "responses": {
    "200": {
      "description": "Profile updated.",
      "body": "{ \"success\": true, \"message\": \"Profile updated successfully.\", \"data\": { \"user\": {/*...*/} } }"
    }
  }
}

Register

{
  "title": "User Registration",
  "description": "Create a new user account and return token.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/register",
  "headers": [],
  "bodyType": "json",
  "requestBody": "{\n  \"name\": \"John Doe\",\n  \"email\": \"[email protected]\",\n  \"password\": \"password123\",\n  \"password_confirmation\": \"password123\"\n}",
  "responses": {
    "201": {
      "description": "Registered successfully.",
      "body": "{ \"success\": true, \"message\": \"User registered successfully\", \"data\": { \"user\": {/*...*/}, \"token\": \"<token>\", \"token_type\": \"Bearer\" } }"
    }
  }
}

List Sessions

{
  "title": "List Sessions",
  "description": "Retrieve all active API sessions for the user.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Sessions list.",
      "body": "{ \"success\": true, \"data\": { \"sessions\": [/*...*/], \"total\": 3 } }"
    }
  }
}

Revoke Session

{
  "title": "Revoke Session",
  "description": "Delete a specific API session by ID.",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/sessions/{sessionId}",
  "pathParams": [
    { "key": "sessionId", "value": "Session token ID", "required": true }
  ],
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Session revoked.",
      "body": "{ \"success\": true, \"message\": \"Session revoked successfully.\" }"
    },
    "404": {
      "description": "Session not found.",
      "body": "{ \"success\": false, \"message\": \"Session not found.\", \"error_code\": \"SESSION_NOT_FOUND\" }"
    }
  }
}

Enable Two-Factor Authentication

{
  "title": "Enable Two-Factor",
  "description": "Initiate 2FA setup and return secret and QR code URL.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/enable",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA setup initiated.",
      "body": "{ \"success\": true, \"message\": \"Two-factor authentication setup initiated.\", \"data\": { \"secret\": \"ABC123\", \"qr_code_url\": \"https://...\" } }"
    }
  }
}

Confirm Two-Factor Authentication

{
  "title": "Confirm Two-Factor",
  "description": "Confirm 2FA by verifying code and receive recovery codes.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/confirm",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\",\n  \"code\": \"123456\"\n}",
  "responses": {
    "200": {
      "description": "2FA enabled.",
      "body": "{ \"success\": true, \"message\": \"Two-factor authentication has been enabled successfully.\", \"data\": { \"recovery_codes\": [\"CODE1\",\"CODE2\"] } }"
    }
  }
}

Get Recovery Codes

{
  "title": "Get Recovery Codes",
  "description": "Retrieve existing two-factor recovery codes.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Recovery codes retrieved.",
      "body": "{ \"success\": true, \"message\": \"Recovery codes retrieved successfully.\", \"data\": { \"recovery_codes\": [\"CODE1\",\"CODE2\"] } }"
    }
  }
}

Regenerate Recovery Codes

{
  "title": "Regenerate Recovery Codes",
  "description": "Invalidate old and create new 2FA recovery codes.",
  "method": "POST",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor/recovery-codes",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "Recovery codes regenerated.",
      "body": "{ \"success\": true, \"message\": \"Recovery codes regenerated successfully.\", \"data\": { \"recovery_codes\": [\"NEW1\",\"NEW2\"] } }"
    }
  }
}

Disable Two-Factor Authentication

{
  "title": "Disable Two-Factor",
  "description": "Disable two-factor authentication for the user.",
  "method": "DELETE",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user/two-factor",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "json",
  "requestBody": "{\n  \"current_password\": \"password123\"\n}",
  "responses": {
    "200": {
      "description": "2FA disabled.",
      "body": "{ \"success\": true, \"message\": \"Two-factor authentication has been disabled successfully.\" }"
    }
  }
}

Get Authenticated User

{
  "title": "Get Current User",
  "description": "Retrieve the authenticated user’s information.",
  "method": "GET",
  "baseUrl": "https://api.example.com",
  "endpoint": "/api/v1/user",
  "headers": [
    { "key": "Authorization", "value": "Bearer <token>", "required": true }
  ],
  "bodyType": "none",
  "responses": {
    "200": {
      "description": "User data returned.",
      "body": "{ \"success\": true, \"data\": { \"user\": {/*...*/} } }"
    }
  }
}

All new APIs use consistent response shapes, enforce validation via FormRequest classes, and apply ApiThrottle rules. The GenerateOpenApiSpec command automates documentation generation for these endpoints.


This documentation was automatically generated by DocuWriter.ai 🚀

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (10)
.github/workflows/tests.yml (2)

49-50: Remove frontend builds from a PHP test job.

You're running pnpm install --frozen-lockfile (line 50) and pnpm run build (line 62) inside a job that only executes ./vendor/bin/pest (line 68). Unless you're running Dusk E2E tests, this is just burning CI minutes for nothing.

If your tests don't need compiled JS:

-      - name: Install Node Dependencies
-        run: pnpm install --frozen-lockfile
-
       - name: Install Dependencies
         run: composer install --no-interaction --prefer-dist --optimize-autoloader
 
       - name: Copy Environment File
         run: cp .env.example .env
 
       - name: Generate Application Key
         run: php artisan key:generate
-
-      - name: Build Assets
-        run: pnpm run build

If you do need frontend artifacts elsewhere (docs, API specs, migrations, etc.), create a separate build job that only installs pnpm and builds, or guard these steps with a conditional:

      - name: Build Assets
        if: contains(github.event.head_commit.modified, 'resources/')
        run: pnpm run build

Also applies to: 61-62


64-65: ☠️ The migrate step makes the database mismatch problem WORSE, not better.

Previous reviews flagged that this workflow runs tests against a different database than where migrations execute:

  • php artisan migrate --force (line 65) reads .env, which defaults to MySQL (or whatever your production DB is).
  • ./vendor/bin/pest (line 68) honors phpunit.xml and uses sqlite :memory:.

Result: You're migrating one database and testing against another. Either:

  1. The migrate step fails hard (no MySQL service in CI), or
  2. It "succeeds" but tests are meaningless because schema doesn't match.

Pick ONE of these sane options:

Option A (Recommended): Use RefreshDatabase trait, drop the global migrate

-      - name: Run Migrations
-        run: php artisan migrate --force
-
       - name: Tests
         run: ./vendor/bin/pest

Then in your test base class, use RefreshDatabase trait to migrate per test.

Option B: Configure DB env vars in the workflow before migrate

       - name: Generate Application Key
         run: php artisan key:generate
+
+      - name: Configure Testing Database
+        run: |
+          echo "DB_CONNECTION=sqlite" >> .env
+          echo "DB_DATABASE=:memory:" >> .env
 
       - name: Run Migrations
         run: php artisan migrate --force

Option C: Run migrate with testing environment

-      - name: Run Migrations
-        run: php artisan migrate --force
+      - name: Run Migrations
+        run: php artisan migrate --env=testing --force

Then ensure config/database.php testing env uses the same sqlite connection.

app/Http/Controllers/Api/Auth/TwoFactorController.php (1)

46-50: Still exposing raw TOTP secret in the response.

The past review flagged this - returning 'secret' => $secret means if anyone logs API responses (and they will), your 2FA secrets are compromised. The QR code URL is necessary, but the raw secret? That's begging to show up in Splunk.

Consider making the secret opt-in via a separate endpoint or a query param, and document that this endpoint's response should NEVER be logged.

tests/Unit/ExampleTest.php (1)

11-26: Missing edge cases from prior review.

The happy path is covered, but what about:

  • 0 seconds (should return "0 seconds")
  • 3600 exactly (should be "1 hour" not "1 hour and 0 minutes")
  • Negative input (should throw or handle gracefully)

The prior review flagged these. Add them to prove your implementation handles boundaries:

// Add these edge case tests
expect(TimeFormatter::secondsToHuman(0))->toBe('0 seconds');
expect(TimeFormatter::secondsToHuman(3600))->toBe('1 hour'); // No trailing "and 0 minutes"
expect(TimeFormatter::secondsToHuman(3601))->toBe('1 hour and 1 second');
storage/postman/api_collection (4)

80-84: Still missing password_confirmation in register payload.

This was flagged before. If your RegisterRequest validates password_confirmation (and it should), this sample will 422 every time.

-"raw": "{\n    \"name\": \"Test User\",\n    \"email\": \"[email protected]\",\n    \"password\": \"password123\"\n}",
+"raw": "{\n    \"name\": \"Test User\",\n    \"email\": \"[email protected]\",\n    \"password\": \"password123\",\n    \"password_confirmation\": \"password123\"\n}",

174-206: GET request with a JSON body is still here. HTTP doesn't work that way.

This was called out twice before. The method is GET (correct), but:

  1. The body is still present (browsers/clients will ignore it)
  2. Content-Type: application/json is pointless for GET
  3. The params need to be query parameters, not body

Fix the URL and nuke the body:

                                "url": {
-                                    "raw": "{{base_url}}/api/v1/email/verify",
+                                    "raw": "{{base_url}}/api/v1/email/verify?id=1&hash=sample_hash&signature=sample_signature&expires=1234567890",
                                    ...
+                                    "query": [
+                                        {"key": "id", "value": "1"},
+                                        {"key": "hash", "value": "sample_hash"},
+                                        {"key": "signature", "value": "sample_signature"},
+                                        {"key": "expires", "value": "1234567890"}
+                                    ]
                                },
-                                "body": {
-                                    "mode": "raw",
-                                    "raw": "{\n    \"id\": 5,\n    \"hash\": \"sample_text\",\n    \"signature\": \"sample_text\",\n    \"expires\": 0\n}",
-                                    "options": []
-                                }

Also remove the Content-Type header at lines 184-187.


158-162: Reset-password also missing password_confirmation.

Same issue as register - if your FormRequest requires confirmation, this sample is DOA.

-"raw": "{\n    \"token\": \"sample_text\",\n    \"email\": \"[email protected]\",\n    \"password\": \"password123\"\n}",
+"raw": "{\n    \"token\": \"sample_text\",\n    \"email\": \"[email protected]\",\n    \"password\": \"password123\",\n    \"password_confirmation\": \"password123\"\n}",

377-380: Password update missing password_confirmation.

Pattern continues. Your UpdatePasswordRequest likely requires confirmation.

-"raw": "{\n    \"current_password\": \"sample_text\",\n    \"password\": \"password123\"\n}",
+"raw": "{\n    \"current_password\": \"sample_text\",\n    \"password\": \"password123\",\n    \"password_confirmation\": \"password123\"\n}",
tests/Feature/ApiTest.php (2)

1032-1034: Token names still identical.

Past review flagged this - both tokens are 'api-token'. The test works because you're revoking by ID, but other tests (like logout-all) use distinct names. Consider 'api-token-1' and 'api-token-2' for consistency.


1105-1128: Still using fake 2FA secret.

Past review flagged this - 'two_factor_secret' => 'test-secret' isn't a valid base32 secret. It works because you're only testing recovery code retrieval, not OTP validation. If someone copies this pattern for a test that needs to validate actual OTPs, they'll have a bad time.

Use 'JBSWY3DPEHPK3PXP' (valid base32) for consistency across tests.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

Cache: Disabled due to Reviews > Disable Cache setting

📥 Commits

Reviewing files that changed from the base of the PR and between f36543b and 5e0e5b0.

📒 Files selected for processing (8)
  • .github/workflows/tests.yml (3 hunks)
  • app/Http/Controllers/Api/Auth/TwoFactorController.php (1 hunks)
  • app/Http/Requests/Api/Auth/RegenerateRecoveryCodesRequest.php (1 hunks)
  • app/Http/Requests/Api/Auth/UpdateProfileRequest.php (1 hunks)
  • app/Http/Resources/UserResource.php (1 hunks)
  • storage/postman/api_collection (2 hunks)
  • tests/Feature/ApiTest.php (1 hunks)
  • tests/Unit/ExampleTest.php (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
tests/Feature/ApiTest.php (8)
app/Models/User.php (1)
  • User (14-70)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (2)
  • UpdateProfileRequest (8-92)
  • authorize (15-46)
app/Http/Requests/Api/Auth/RegenerateRecoveryCodesRequest.php (1)
  • authorize (15-18)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1)
  • authorize (15-18)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)
  • authorize (15-18)
app/Http/Requests/Api/Auth/DeleteAccountRequest.php (1)
  • authorize (15-18)
app/Http/Requests/Api/Auth/VerifyEmailRequest.php (1)
  • authorize (12-15)
app/Http/Requests/Api/Auth/UpdatePasswordRequest.php (1)
  • authorize (15-18)
tests/Unit/ExampleTest.php (3)
app/Http/Resources/UserResource.php (2)
  • UserResource (8-24)
  • toArray (15-23)
app/Support/TimeFormatter.php (2)
  • TimeFormatter (5-69)
  • secondsToHuman (23-68)
app/Helpers/ApiResponse.php (1)
  • secondsToHuman (113-116)
app/Http/Requests/Api/Auth/RegenerateRecoveryCodesRequest.php (1)
app/Http/Requests/Api/Auth/UpdateProfileRequest.php (3)
  • authorize (15-46)
  • rules (53-72)
  • messages (79-91)
app/Http/Controllers/Api/Auth/TwoFactorController.php (4)
app/Helpers/ApiResponse.php (3)
  • ApiResponse (7-117)
  • error (28-46)
  • success (12-23)
app/Http/Requests/Api/Auth/ConfirmTwoFactorRequest.php (1)
  • ConfirmTwoFactorRequest (7-47)
app/Http/Requests/Api/Auth/DisableTwoFactorRequest.php (1)
  • DisableTwoFactorRequest (7-42)
app/Http/Requests/Api/Auth/EnableTwoFactorRequest.php (1)
  • EnableTwoFactorRequest (7-44)
🪛 PHPMD (2.15.0)
app/Http/Controllers/Api/Auth/TwoFactorController.php

27-27: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'enable'. (undefined)

(StaticAccess)


46-50: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'enable'. (undefined)

(StaticAccess)


61-61: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


64-64: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


72-72: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


84-87: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'confirm'. (undefined)

(StaticAccess)


96-96: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


103-103: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


107-107: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


112-112: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


115-118: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


127-127: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'regenerateRecoveryCodes'. (undefined)

(StaticAccess)


138-141: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'regenerateRecoveryCodes'. (undefined)

(StaticAccess)


150-150: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'disable'. (undefined)

(StaticAccess)


160-160: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'disable'. (undefined)

(StaticAccess)

app/Http/Resources/UserResource.php

15-15: Avoid unused parameters such as '$request'. (undefined)

(UnusedFormalParameter)

🔇 Additional comments (21)
app/Http/Requests/Api/Auth/RegenerateRecoveryCodesRequest.php (2)

25-30: Solid validation for a sensitive operation.

Requiring current_password to regenerate recovery codes is the right call—prevents some clown who yoinked an unlocked device from nuking the user's 2FA safety net. The current_password:sanctum guard is chef's kiss for API routes.


37-43: Clean, no notes.

Custom validation messages are clear and user-friendly. This is the unglamorous bread-and-butter work that makes APIs not suck.

app/Http/Requests/Api/Auth/UpdateProfileRequest.php (2)

15-46: Authorization logic is solid - good job addressing the UUID concerns.

The string comparison fix from prior reviews is in place. The defensive programming with both route and body ID checks is a nice belt-and-suspenders approach.


79-91: Custom messages are clean.

Standard Laravel pattern, nothing fancy but gets the job done.

app/Http/Controllers/Api/Auth/TwoFactorController.php (4)

163-177: Recovery code generation looks good now.

8-char alphanumeric with 47+ bits of entropy per code. The math checks out: 62^8 ≈ 218 trillion. Much better UX than raw hex, and the entropy is solid for recovery codes.


21-51: Enable flow is clean aside from the secret exposure.

The check for two_factor_confirmed_at before allowing re-enable is good. forceFill with immediate save is the right call for sensitive fields.


53-88: Confirm flow is solid.

Proper DecryptException handling, code verification with 1-window tolerance, and atomic save of recovery codes + confirmation timestamp. Good stuff.


144-161: Disable now requires password - security concern addressed.

Using DisableTwoFactorRequest with password validation. Proper cleanup of all 2FA fields.

app/Http/Resources/UserResource.php (1)

15-23: Date formatting is now explicit - past concern addressed.

Using toISOString() locks down the API contract. The null-safe operator handles unverified users cleanly. The PHPMD warning about unused $request is a false positive - it's required by Laravel's JsonResource interface.

storage/postman/api_collection (1)

948-969: Collection-level auth and variables are properly configured.

Bearer token setup with {{auth_token}} variable. Per-request auth overrides where needed. This is the right pattern for Postman.

tests/Feature/ApiTest.php (11)

21-47: LGTM!

Clean, focused tests. Unauthenticated gets 401, authenticated gets the goods. No nonsense.


49-94: LGTM!

Registration test is thorough - creates user, checks DB, verifies hashing. The 'token' => true trick on line 68 is a bit galaxy-brained but it works since you're also validating structure below. Password hash verification is chef's kiss.


132-212: LGTM!

The login tests are solid now. Love that you fixed the tautological token assertion - expect($token)->toBeString()->not->toBeEmpty() is the way. Multi-device token test actually proves the tokens are different. Database assertions verify the token names. No complaints.


384-400: Recovery codes validation is actually useful now.

Nice - you're checking it's an array, not empty, and each code matches the ^[A-Za-z0-9]{8}$ pattern. This actually validates structure instead of just echoing the response back. Good fix.


492-527: LGTM!

Password update test does the right thing - changes password, then proves it works by logging in with the new creds. That's the kind of end-to-end validation that actually catches bugs. Wrong password rejection is properly tested too.


590-639: LGTM!

Fixed the duplicate device name issue - now using device1 and device2. The logout-all test properly creates distinct tokens, verifies they exist, nukes them all, and confirms the apocalypse via assertDatabaseMissing. Clean.


719-812: Respect for the type juggling test.

This is actually important security testing. PHP's loose comparison can let "0" == 0 pass, and testing that your authorize() method handles string/int ID comparisons correctly is the kind of paranoid testing that prevents IDOR vulnerabilities. The anonymous class approach is verbose but it isolates the authorization logic perfectly.


865-908: LGTM!

Email verification test is solid. You extracted the URL parsing to getSignedUrlParams helper as suggested in past reviews. Tests the full flow: generate signed URL, extract params, verify endpoint, confirm email_verified_at is set. Error cases covered too.


1183-1195: Good use of the constant.

TwoFactorController::RECOVERY_CODE_COUNT instead of magic 9. This keeps tests and implementation in sync. If someone changes the count, both break together instead of silently diverging. That's the way.


1534-1542: LGTM!

Fixed the weird "query relationships on a deleted model" issue. Now you're doing a proper direct query against PersonalAccessToken table instead of calling $user->tokens() on a ghost. Much cleaner and semantically correct.


1335-1366: LGTM!

Account deactivation test is thorough - verifies the flag is set, reason is saved, timestamp exists, and all tokens are nuked. Good coverage of the business logic.

Comment on lines +90 to +119
public function recoveryCodes(Request $request): JsonResponse
{
$user = $request->user();

// Check if 2FA is enabled
if (! $user->two_factor_confirmed_at) {
return ApiResponse::error('Two-factor authentication is not enabled.', null, 400, 'TWO_FACTOR_NOT_ENABLED');
}

try {
// Recovery codes are automatically decrypted by the encrypted cast
$recoveryCodes = $user->two_factor_recovery_codes;
} catch (DecryptException) {
return ApiResponse::error('Unable to decrypt recovery codes. The data may be corrupted.', null, 422, 'RECOVERY_CODES_DECRYPTION_FAILED');
}

if (! $recoveryCodes) {
return ApiResponse::error('No recovery codes available.', null, 400, 'NO_RECOVERY_CODES');
}

$decodedCodes = json_decode($recoveryCodes, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ApiResponse::error('Recovery codes data is corrupted.', null, 422, 'RECOVERY_CODES_CORRUPTED');
}

return ApiResponse::success([
'recovery_codes' => $decodedCodes,
'warning' => 'Make sure to save your recovery codes in a secure place.',
], 'Recovery codes retrieved successfully.');
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

recoveryCodes() is the odd one out - no password verification.

You've got EnableTwoFactorRequest, ConfirmTwoFactorRequest, DisableTwoFactorRequest, and RegenerateRecoveryCodesRequest all requiring current_password. But viewing recovery codes? Just need a session.

An attacker with session access can view (and screenshot/copy) recovery codes without any additional verification. This is arguably as sensitive as regenerating them.

Create a ViewRecoveryCodesRequest with password validation:

-    public function recoveryCodes(Request $request): JsonResponse
+    public function recoveryCodes(ViewRecoveryCodesRequest $request): JsonResponse

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 PHPMD (2.15.0)

96-96: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


103-103: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


107-107: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


112-112: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)


115-118: Avoid using static access to class '\App\Helpers\ApiResponse' in method 'recoveryCodes'. (undefined)

(StaticAccess)

🤖 Prompt for AI Agents
In app/Http/Controllers/Api/Auth/TwoFactorController.php around lines 90 to 119,
the recoveryCodes() action returns sensitive recovery codes without re-checking
the user's current password; create a new Form Request (e.g.,
ViewRecoveryCodesRequest) that validates the current_password against the
authenticated user (same rules as the other two-factor requests), change the
controller method signature to accept that request instead of generic Request,
import the new request class, and rely on its validated authorization to ensure
only users who re-enter their password can retrieve recovery codes; also update
any route or controller binding to use the new request.

Comment on lines +15 to +18
public function authorize(): bool
{
return auth()->check();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "regenerate.*recovery|recovery.*regenerate" routes/ -B 5 -A 5

Repository: dash-soft/DashOps

Length of output: 1278


🏁 Script executed:

rg -n "auth:sanctum" routes/ -B 2 -A 2

Repository: dash-soft/DashOps

Length of output: 520


🏁 Script executed:

sed -n '20,60p' routes/api.php

Repository: dash-soft/DashOps

Length of output: 2708


The "defense in depth" framing is overstated, but the code is fine.

The regenerate recovery codes route is already wrapped in the auth:sanctum middleware group (routes/api.php:26), so auth()->check() in the authorize() method is redundant—you're checking that the middleware did its job. That's not really "defense in depth"; it's just belt and suspenders on the same defensive layer.

That said, for this use case it's perfectly acceptable. Recovery code regeneration operates on the authenticated user implicitly (no route parameter to validate), and the extra auth()->check() adds negligible overhead while providing a safety net if something goes wrong with middleware configuration.

🤖 Prompt for AI Agents
In app/Http/Requests/Api/Auth/RegenerateRecoveryCodesRequest.php around lines 15
to 18, the authorize() method redundantly calls auth()->check() even though the
route is already protected by auth:sanctum; replace the auth()->check() call
with a simple return true (or remove the method entirely if not needed) so the
request class doesn't re-check middleware-level authentication, keeping the
route protection responsibility with the middleware.

Comment on lines +53 to +72
public function rules(): array
{
$rules = [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($this->user()?->id),
],
];

// Require current password when changing email for security
if ($this->has('email') && $this->email !== $this->user()?->email) {
$rules['current_password'] = ['required', 'string', 'current_password:sanctum'];
}

return $rules;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor inconsistency: ->id vs ->getKey() usage.

You're using getKey() for comparisons in authorize() but ->id in the unique rule ignore. If you ever switch to UUIDs with a different primary key name, this could bite you.

             'email' => [
                 'required',
                 'string',
                 'email',
                 'max:255',
-                Rule::unique('users')->ignore($this->user()?->id),
+                Rule::unique('users')->ignore($this->user()?->getKey()),
             ],
🤖 Prompt for AI Agents
In app/Http/Requests/Api/Auth/UpdateProfileRequest.php around lines 53 to 72,
the unique rule currently ignores the user by ->id which is inconsistent with
authorize() using getKey(); change the ignore value to use
$this->user()?->getKey() (preserving the null-safe operator) so the rule works
correctly with non-standard PK names (e.g., UUIDs) and remains consistent across
the class.

Comment on lines +13 to +19
function getSignedUrlParams(string $signedUrl): array
{
$urlParts = parse_url($signedUrl);
parse_str($urlParts['query'], $queryParams);

return $queryParams;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Helper could blow up on malformed URLs.

If someone passes a URL without a query string, $urlParts['query'] will be undefined and parse_str gets null. In test code this is probably fine since you're always passing signed URLs, but a null coalesce would be trivially defensive:

 function getSignedUrlParams(string $signedUrl): array
 {
     $urlParts = parse_url($signedUrl);
-    parse_str($urlParts['query'], $queryParams);
+    parse_str($urlParts['query'] ?? '', $queryParams);

     return $queryParams;
 }
🤖 Prompt for AI Agents
In tests/Feature/ApiTest.php around lines 13-19, the helper assumes
$urlParts['query'] exists and will pass null to parse_str for malformed URLs;
change it to defensively handle missing query by using a null-coalescing default
(e.g. $urlParts['query'] ?? '') when calling parse_str, and ensure $queryParams
is initialized or return an empty array when no query string is present.

Comment on lines +261 to +271
$response->assertOk()
->assertJson([
'success' => true,
'message' => 'Two-factor authentication setup initiated.',
'data' => [
'secret' => $response->json('data.secret'),
'qr_code_url' => $response->json('data.qr_code_url'),
'next_step' => 'Scan the QR code with your authenticator app and confirm with the generated code.',
],
'errors' => null,
]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Self-referential assertions strike again.

Lines 266-267 assert that secret and qr_code_url equal... themselves. This passes for literally any non-null value. You're not validating anything meaningful here.

     $response->assertOk()
         ->assertJson([
             'success' => true,
             'message' => 'Two-factor authentication setup initiated.',
             'data' => [
-                'secret' => $response->json('data.secret'),
-                'qr_code_url' => $response->json('data.qr_code_url'),
                 'next_step' => 'Scan the QR code with your authenticator app and confirm with the generated code.',
             ],
             'errors' => null,
-        ]);
+        ])
+        ->assertJsonPath('data.secret', fn ($s) => is_string($s) && strlen($s) >= 16)
+        ->assertJsonPath('data.qr_code_url', fn ($url) => str_starts_with($url, 'otpauth://'));

Comment on lines +28 to +70
test('user resource formats email_verified_at correctly', function (): void {
$mockRequest = new \Illuminate\Http\Request;

// Create a mock user with null email_verified_at
$unverifiedUser = new class
{
public $id = 1;

public $name = 'Test User';

public $email = '[email protected]';

public $email_verified_at = null;
};

$resource = UserResource::make($unverifiedUser);
$data = $resource->toArray($mockRequest);

expect($data['email_verified_at'])->toBeNull();

// Create a mock user with verified email
$verifiedAt = now();
$verifiedUser = new class($verifiedAt)
{
public $id = 2;

public $name = 'Verified User';

public $email = '[email protected]';

public $email_verified_at;

public function __construct($verifiedAt)
{
$this->email_verified_at = $verifiedAt;
}
};

$resource = UserResource::make($verifiedUser);
$data = $resource->toArray($mockRequest);

expect($data['email_verified_at'])->toBeString();
expect($data['email_verified_at'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/'); // ISO8601 format
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

UserResource tests are solid.

Anonymous classes for mocks is a bit unconventional but works. The ISO8601 regex validation is a smart way to verify format without being brittle to exact timestamps.

One small improvement - use Carbon::createFromTimestamp(0) or a fixed date for deterministic tests instead of now().

-    $verifiedAt = now();
+    $verifiedAt = \Carbon\Carbon::parse('2024-01-15T10:30:00Z');
🤖 Prompt for AI Agents
In tests/Unit/ExampleTest.php around lines 28 to 70, the test uses now() to set
$verifiedAt which makes the test non-deterministic; replace the dynamic now()
with a fixed deterministic Carbon instance (e.g. Carbon::createFromTimestamp(0)
or Carbon::parse('2020-01-01 00:00:00')) and update the mock creation to use
that fixed value so assertions against the ISO8601 formatted string are stable
across runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants