• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / auth / 25552335000

08 May 2026 11:01AM UTC coverage: 71.592% (+12.6%) from 59.035%
25552335000

push

github

daycry
tests: bring coverage from 58% to 71% with 180 new tests

Adds 22 new test files covering services, models, filters, traits, commands,
exceptions, and password validators. Test count goes from 732 to 912.

Bug fixes uncovered while writing tests:
- Totp2FA::verifyCodeForUser: was passing the encrypted+base64 secret to
  TOTP::verify, which then tried to base32-decode binary garbage. Now uses
  the user's getTotpSecret() so the decrypted base32 secret reaches verify.
- HasAccessTokens trait: accessTokens() and revokeAllAccessTokens() called
  non-existent plural method names on UserIdentityModel; refactored the
  trait to delegate to AccessTokenRepository (the architectural direction).
- PasswordChangeRecorder::stampChangedAt: silently dropped the timestamp
  because password_changed_at isn't in UserModel::$allowedFields. Switched
  to a direct query builder update.
- Five admin commands (audit, sessions, tokens, totp, gdpr): used the static
  CLI::write/error/getOption helpers, which bypass the MockInputOutput
  interceptor used by tests. Switched to instance helpers and \$params.

Baseline regenerated to absorb the new (and legitimate) deprecated-method
and internal-helper warnings raised by tests that intentionally exercise
deprecated APIs and the BaseCommand setInputOutput hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

59 of 69 new or added lines in 8 files covered. (85.51%)

1 existing line in 1 file now uncovered.

4453 of 6220 relevant lines covered (71.59%)

62.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.44
/src/Commands/GdprCommand.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Auth\Commands;
15

16
use CodeIgniter\I18n\Time;
17
use Daycry\Auth\Authentication\Authenticators\AccessToken;
18
use Daycry\Auth\Entities\User;
19
use Daycry\Auth\Enums\IdentityType;
20
use Daycry\Auth\Models\AuditLogModel;
21
use Daycry\Auth\Models\DeviceSessionModel;
22
use Daycry\Auth\Models\LoginModel;
23
use Daycry\Auth\Models\PasswordHistoryModel;
24
use Daycry\Auth\Models\TotpBackupCodeModel;
25
use Daycry\Auth\Models\UserIdentityModel;
26
use Daycry\Auth\Models\UserModel;
27
use Daycry\Auth\Services\AuditLogger;
28
use Throwable;
29

30
/**
31
 * GDPR-friendly data export and account anonymization.
32
 *
33
 * Usage:
34
 *   php spark auth:gdpr export -e <email> [-o <path>]
35
 *   php spark auth:gdpr anonymize -e <email>
36
 *
37
 * `export` writes a JSON document containing the user's row, identities
38
 * (with secrets redacted), device sessions, login history, audit log
39
 * entries, and password-history metadata.
40
 *
41
 * `anonymize` keeps the user row (preserving FK integrity) but replaces
42
 * personal fields with placeholders, deletes identities/tokens/device
43
 * sessions/password-history/backup-codes, and writes an audit-log entry.
44
 */
45
class GdprCommand extends BaseCommand
46
{
47
    protected $name        = 'auth:gdpr';
48
    protected $description = 'GDPR helpers: export user data / anonymize a user account.';
49
    protected $usage       = <<<'EOL'
50
        auth:gdpr export -e <email> [-o <path>]
51
        auth:gdpr anonymize -e <email>
52
        EOL;
53

54
    /**
55
     * @var array<string, string>
56
     */
57
    protected $options = [
58
        '-e' => 'Target user email.',
59
        '-i' => 'Target user id (alternative to -e).',
60
        '-o' => 'Output file path (export only). Defaults to stdout.',
61
    ];
62

63
    public function run(array $params): int
9✔
64
    {
65
        $action = $params[0] ?? '';
9✔
66
        $email  = (string) ($params['e'] ?? '');
9✔
67
        $id     = (string) ($params['i'] ?? '');
9✔
68

69
        if ($email === '' && $id === '') {
9✔
70
            $this->error('Specify -e <email> or -i <id>.');
1✔
71

72
            return 1;
1✔
73
        }
74

75
        /** @var UserModel $userModel */
76
        $userModel = model(UserModel::class);
8✔
77
        $user      = $id !== ''
8✔
78
            ? $userModel->findById((int) $id)
1✔
79
            : $userModel->findByCredentials(['email' => $email]);
7✔
80

81
        if (! $user instanceof User) {
8✔
82
            $this->error('User not found.');
1✔
83

84
            return 1;
1✔
85
        }
86

87
        return match ($action) {
7✔
88
            'export'    => $this->exportAction($user, (string) ($params['o'] ?? '')),
4✔
89
            'anonymize' => $this->anonymizeAction($user, $userModel),
2✔
90
            default     => $this->unsupported(),
7✔
91
        };
7✔
92
    }
93

94
    private function unsupported(): int
1✔
95
    {
96
        $this->error('Unsupported action. Supported: export, anonymize.');
1✔
97

98
        return 1;
1✔
99
    }
100

101
    private function exportAction(User $user, string $outputPath = ''): int
5✔
102
    {
103
        try {
104
            $payload = [
5✔
105
                'exported_at' => Time::now()->toDateTimeString(),
5✔
106
                'user'        => [
5✔
107
                    'id'                  => $user->id,
5✔
108
                    'uuid'                => $user->uuid ?? null,
5✔
109
                    'username'            => $user->username,
5✔
110
                    'email'               => $user->email ?? null,
5✔
111
                    'active'              => (bool) ($user->active ?? false),
5✔
112
                    'created_at'          => (string) ($user->created_at ?? ''),
5✔
113
                    'updated_at'          => (string) ($user->updated_at ?? ''),
5✔
114
                    'failed_login_count'  => $user->failed_login_count ?? 0,
5✔
115
                    'locked_until'        => $user->locked_until ?? null,
5✔
116
                    'password_changed_at' => $user->password_changed_at ?? null,
5✔
117
                ],
5✔
118
                'identities'       => $this->collectIdentities($user),
5✔
119
                'device_sessions'  => $this->collectDeviceSessions($user),
5✔
120
                'login_history'    => $this->collectLoginHistory($user),
5✔
121
                'audit_log'        => $this->collectAuditLog($user),
5✔
122
                'password_history' => $this->collectPasswordHistoryMeta($user),
5✔
123
                'backup_codes'     => $this->collectBackupCodesMeta($user),
5✔
124
            ];
5✔
125

126
            $json = json_encode(
5✔
127
                $payload,
5✔
128
                JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
5✔
129
            );
5✔
130

131
            if ($json === false) {
5✔
NEW
132
                $this->error('JSON encoding failed.');
×
133

134
                return 1;
×
135
            }
136

137
            if ($outputPath !== '') {
5✔
138
                file_put_contents($outputPath, $json);
1✔
139
                $this->write('Wrote export to ' . $outputPath, 'green');
1✔
140
            } else {
141
                $this->write($json);
4✔
142
            }
143

144
            return 0;
5✔
145
        } catch (Throwable $e) {
×
NEW
146
            $this->error('Export failed: ' . $e->getMessage());
×
147

148
            return 1;
×
149
        }
150
    }
151

152
    private function anonymizeAction(User $user, UserModel $userModel): int
2✔
153
    {
154
        if ($this->prompt('This will permanently anonymize user ' . $user->id . '. Continue?', ['y', 'n']) !== 'y') {
2✔
155
            $this->write('Aborted.', 'yellow');
1✔
156

157
            return 0;
1✔
158
        }
159

160
        try {
161
            $userId = (int) $user->id;
1✔
162

163
            // Soft-revoke and delete identities (passwords, tokens, OAuth).
164
            /** @var UserIdentityModel $identityModel */
165
            $identityModel = model(UserIdentityModel::class);
1✔
166
            $identityModel->where('user_id', $userId)->delete();
1✔
167

168
            // Drop device sessions / login history / backup codes /
169
            // password history. We keep audit log entries with user_id set
170
            // but the row itself anonymised below.
171
            /** @var DeviceSessionModel $deviceModel */
172
            $deviceModel = model(DeviceSessionModel::class);
1✔
173
            $deviceModel->where('user_id', $userId)->delete();
1✔
174

175
            /** @var TotpBackupCodeModel $backup */
176
            $backup = model(TotpBackupCodeModel::class);
1✔
177
            $backup->where('user_id', $userId)->delete();
1✔
178

179
            /** @var PasswordHistoryModel $history */
180
            $history = model(PasswordHistoryModel::class);
1✔
181
            $history->where('user_id', $userId)->delete();
1✔
182

183
            // Anonymise the user row (preserve FKs).
184
            $userModel->where('id', $userId)->set([
1✔
185
                'username'            => 'deleted_' . $userId,
1✔
186
                'active'              => 0,
1✔
187
                'failed_login_count'  => 0,
1✔
188
                'locked_until'        => null,
1✔
189
                'password_changed_at' => null,
1✔
190
            ])->update();
1✔
191

192
            // Final audit-log entry with anonymisation.
193
            (new AuditLogger())->record(
1✔
194
                AuditLogger::EVENT_USER_ANONYMIZED,
1✔
195
                $userId,
1✔
196
                ['initiator' => 'cli'],
1✔
197
            );
1✔
198

199
            $this->write('User ' . $userId . ' anonymised. Identities and tokens removed.', 'green');
1✔
200

201
            return 0;
1✔
202
        } catch (Throwable $e) {
×
NEW
203
            $this->error('Anonymization failed: ' . $e->getMessage());
×
204

205
            return 1;
×
206
        }
207
    }
208

209
    /**
210
     * @return list<array<string, mixed>>
211
     */
212
    private function collectIdentities(User $user): array
5✔
213
    {
214
        /** @var UserIdentityModel $identityModel */
215
        $identityModel = model(UserIdentityModel::class);
5✔
216

217
        $rows   = $identityModel->where('user_id', $user->id)->findAll();
5✔
218
        $result = [];
5✔
219

220
        foreach ($rows as $row) {
5✔
221
            $type = is_object($row) ? ($row->type ?? '') : ($row['type'] ?? '');
5✔
222

223
            $entry = [
5✔
224
                'id'           => is_object($row) ? ($row->id ?? null) : ($row['id'] ?? null),
5✔
225
                'type'         => $type,
5✔
226
                'name'         => is_object($row) ? ($row->name ?? null) : ($row['name'] ?? null),
5✔
227
                'expires'      => is_object($row) ? ($row->expires ?? null) : ($row['expires'] ?? null),
5✔
228
                'last_used_at' => is_object($row) ? ($row->last_used_at ?? null) : ($row['last_used_at'] ?? null),
5✔
229
                'revoked_at'   => is_object($row) ? ($row->revoked_at ?? null) : ($row['revoked_at'] ?? null),
5✔
230
                'created_at'   => is_object($row) ? ($row->created_at ?? null) : ($row['created_at'] ?? null),
5✔
231
            ];
5✔
232

233
            // Redact secret fields — they are not portable user data and
234
            // exposing them would defeat the purpose of revoking a token.
235
            if ($type === IdentityType::EMAIL_PASSWORD->value) {
5✔
236
                $entry['secret']  = is_object($row) ? ($row->secret ?? null) : ($row['secret'] ?? null);
5✔
237
                $entry['secret2'] = '<redacted: bcrypt hash>';
5✔
238
            } elseif ($type === AccessToken::ID_TYPE_ACCESS_TOKEN || $type === IdentityType::JWT_REFRESH->value) {
1✔
239
                $entry['secret']  = '<redacted: hashed token>';
1✔
240
                $entry['secret2'] = null;
1✔
241
            } else {
242
                $entry['secret']  = is_object($row) ? ($row->secret ?? null) : ($row['secret'] ?? null);
×
243
                $entry['secret2'] = is_object($row) ? ($row->secret2 ?? null) : ($row['secret2'] ?? null);
×
244
            }
245

246
            $result[] = $entry;
5✔
247
        }
248

249
        return $result;
5✔
250
    }
251

252
    /**
253
     * @return list<array<string, mixed>>
254
     */
255
    private function collectDeviceSessions(User $user): array
5✔
256
    {
257
        /** @var DeviceSessionModel $deviceModel */
258
        $deviceModel = model(DeviceSessionModel::class);
5✔
259

260
        $rows   = $deviceModel->where('user_id', $user->id)->findAll();
5✔
261
        $result = [];
5✔
262

263
        foreach ($rows as $row) {
5✔
264
            $result[] = [
×
265
                'uuid'          => is_object($row) ? ($row->uuid ?? null) : ($row['uuid'] ?? null),
×
266
                'session_id'    => is_object($row) ? ($row->session_id ?? null) : ($row['session_id'] ?? null),
×
267
                'device_name'   => is_object($row) ? ($row->device_name ?? null) : ($row['device_name'] ?? null),
×
268
                'ip_address'    => is_object($row) ? ($row->ip_address ?? null) : ($row['ip_address'] ?? null),
×
269
                'user_agent'    => is_object($row) ? ($row->user_agent ?? null) : ($row['user_agent'] ?? null),
×
270
                'last_active'   => is_object($row) ? ($row->last_active ?? null) : ($row['last_active'] ?? null),
×
271
                'logged_out_at' => is_object($row) ? ($row->logged_out_at ?? null) : ($row['logged_out_at'] ?? null),
×
272
                'trusted_until' => is_object($row) ? ($row->trusted_until ?? null) : ($row['trusted_until'] ?? null),
×
273
                'created_at'    => is_object($row) ? ($row->created_at ?? null) : ($row['created_at'] ?? null),
×
274
            ];
×
275
        }
276

277
        return $result;
5✔
278
    }
279

280
    /**
281
     * @return list<array<string, mixed>>
282
     */
283
    private function collectLoginHistory(User $user): array
5✔
284
    {
285
        /** @var LoginModel $loginModel */
286
        $loginModel = model(LoginModel::class);
5✔
287

288
        $rows   = $loginModel->recentForUser($user, 500);
5✔
289
        $result = [];
5✔
290

291
        foreach ($rows as $row) {
5✔
292
            $result[] = [
×
293
                'date'       => (string) ($row->date ?? ''),
×
294
                'success'    => (bool) ($row->success ?? false),
×
295
                'id_type'    => (string) ($row->id_type ?? ''),
×
296
                'identifier' => (string) ($row->identifier ?? ''),
×
297
                'ip_address' => (string) ($row->ip_address ?? ''),
×
298
                'user_agent' => (string) ($row->user_agent ?? ''),
×
299
            ];
×
300
        }
301

302
        return $result;
5✔
303
    }
304

305
    /**
306
     * @return list<array<string, mixed>>
307
     */
308
    private function collectAuditLog(User $user): array
5✔
309
    {
310
        /** @var AuditLogModel $auditModel */
311
        $auditModel = model(AuditLogModel::class);
5✔
312
        $rows       = $auditModel->recentForUser((int) $user->id, 500);
5✔
313
        $result     = [];
5✔
314

315
        foreach ($rows as $row) {
5✔
316
            $result[] = [
×
317
                'created_at' => (string) ($row->created_at ?? ''),
×
318
                'event_type' => (string) ($row->event_type ?? ''),
×
319
                'ip_address' => (string) ($row->ip_address ?? ''),
×
320
                'user_agent' => (string) ($row->user_agent ?? ''),
×
321
                'metadata'   => $row->getMetadata(),
×
322
            ];
×
323
        }
324

325
        return $result;
5✔
326
    }
327

328
    /**
329
     * Returns metadata about historic password hashes (count + dates only;
330
     * never the hashes themselves — those are bcrypt strings, not user data).
331
     *
332
     * @return array<string, mixed>
333
     */
334
    private function collectPasswordHistoryMeta(User $user): array
5✔
335
    {
336
        /** @var PasswordHistoryModel $history */
337
        $history = model(PasswordHistoryModel::class);
5✔
338

339
        $count = $history->where('user_id', $user->id)->countAllResults();
5✔
340

341
        return ['count' => $count];
5✔
342
    }
343

344
    /**
345
     * Returns counts of remaining/used backup codes.
346
     *
347
     * @return array<string, int>
348
     */
349
    private function collectBackupCodesMeta(User $user): array
5✔
350
    {
351
        /** @var TotpBackupCodeModel $backup */
352
        $backup = model(TotpBackupCodeModel::class);
5✔
353

354
        $remaining = $backup->where('user_id', $user->id)->where('used_at')->countAllResults();
5✔
355
        $used      = $backup->where('user_id', $user->id)->where('used_at !=')->countAllResults();
5✔
356

357
        return ['remaining' => $remaining, 'used' => $used];
5✔
358
    }
359
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc