Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.15% covered (success)
96.15%
25 / 26
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SetInvestorPasswordAction
96.15% covered (success)
96.15%
25 / 26
66.67% covered (warning)
66.67%
2 / 3
10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 requireAdmin
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Admin;
6
7use App\Domain\Auth\Data\AdminActor;
8use App\Domain\Auth\Data\UserAuthData;
9use App\Domain\Auth\Service\AdminPasswordService;
10use App\Domain\Exception\AuthenticationException;
11use App\Domain\Exception\BadRequestException;
12use App\Domain\Exception\ForbiddenException;
13use App\Domain\Exception\ValidationException;
14use App\Renderer\JsonRenderer;
15use App\Support\Row;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18
19/**
20 * POST /api/admin/investors/{investorId}/set-password
21 *
22 * Admin sets a new password directly on a customer's account (FSC-124).
23 * Body: { password }.
24 *
25 * The /api/admin group is JWT-gated but not role-gated, so — like
26 * ImpersonateUserAction — this verifies the caller is staff in-action; the
27 * service then refuses to target another staff account and enforces strength.
28 */
29final readonly class SetInvestorPasswordAction
30{
31    public function __construct(
32        private AdminPasswordService $adminPasswordService,
33        private JsonRenderer $renderer,
34    ) {}
35
36    /**
37     * @param array<string, string> $args
38     * @param ServerRequestInterface $request
39     * @param ResponseInterface $response
40     */
41    public function __invoke(
42        ServerRequestInterface $request,
43        ResponseInterface $response,
44        array $args,
45    ): ResponseInterface {
46        $actor = $this->requireAdmin($request);
47
48        $investorId = (int)($args['investorId'] ?? 0);
49        if ($investorId <= 0) {
50            throw new ValidationException('Invalid investor ID');
51        }
52
53        $data = (array)$request->getParsedBody();
54        $password = Row::nullableString($data, 'password') ?? '';
55        if ($password === '') {
56            throw new BadRequestException('New password is required');
57        }
58
59        // Strength is validated inside the service (PasswordService), so the
60        // same policy applies to admin-set and self-service passwords.
61        $target = $this->adminPasswordService->setPasswordForInvestor($investorId, $password, $actor);
62
63        return $this->renderer->json($response, [
64            'success' => true,
65            'message' => "Password updated for {$target->username}.",
66        ]);
67    }
68
69    private function requireAdmin(ServerRequestInterface $request): AdminActor
70    {
71        $user = $request->getAttribute('user');
72        if (!$user instanceof UserAuthData) {
73            throw new AuthenticationException('User not authenticated');
74        }
75        if ($user->role !== 'admin' && $user->role !== 'super_admin') {
76            throw new ForbiddenException('Only admins can set customer passwords');
77        }
78
79        $clientIp = $request->getAttribute('clientIp');
80
81        return new AdminActor(
82            userId: $user->userId,
83            username: $user->username,
84            ipAddress: is_string($clientIp) ? $clientIp : null,
85            userAgent: $request->getHeaderLine('User-Agent') ?: null,
86        );
87    }
88}