1
<?php
2
declare(strict_types=1);
3
4
use Machweis\Backend\Auth\TokenService;
5
use Machweis\Backend\Config;
6
use Machweis\Backend\Kernel;
7
use Machweis\Backend\Logger;
8
use Machweis\Backend\ReportAssetService;
9
use Machweis\Backend\ReportService;
10
use Machweis\Backend\Request;
11
12
require dirname(__DIR__) . '/src/autoload.php';
13
14
function assetCheck(bool $condition, string $message): void
15
{
16
if (!$condition) throw new RuntimeException($message);
17
}
18
19
function assetUuid(): string
20
{
21
$bytes = random_bytes(16);
22
$bytes[6] = chr((ord($bytes[6]) & 15) | 64);
23
$bytes[8] = chr((ord($bytes[8]) & 63) | 128);
24
$hex = bin2hex($bytes);
25
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
26
}
27
28
/** @param array<string, mixed> $metadata */
29
function startAsset(Kernel $kernel, string $token, array $metadata): Machweis\Backend\Response
30
{
31
return $kernel->handle(new Request('POST', '/api/v1/report-assets/uploads', ['authorization' => 'Bearer ' . $token], json_encode($metadata, JSON_THROW_ON_ERROR)));
32
}
33
34
function putAsset(Kernel $kernel, string $token, string $uploadId, int $offset, string $bytes): Machweis\Backend\Response
35
{
36
return $kernel->handle(new Request('PUT', '/api/v1/report-assets/uploads/' . $uploadId, [
37
'authorization' => 'Bearer ' . $token,
38
'x-upload-offset' => (string) $offset,
39
'x-chunk-sha256' => hash('sha256', $bytes),
40
'content-type' => 'application/octet-stream',
41
], $bytes));
42
}
43
44
$storage = sys_get_temp_dir() . '/machweis-assets-contract-' . bin2hex(random_bytes(6));
45
$config = new Config(
46
'test', false, (string) getenv('DB_HOST'), (int) getenv('DB_PORT'), (string) getenv('DB_NAME'),
47
(string) getenv('DB_USER'), (string) getenv('DB_PASSWORD'), [], true, str_repeat('assets-contract-secret-', 2),
48
900, 2592000, $storage, 'asset-cleanup-test', str_repeat('cleanup-contract-secret-', 2),
49
);
50
$pdo = new PDO(
51
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $config->dbHost, $config->dbPort, $config->dbName),
52
$config->dbUser,
53
$config->dbPassword,
54
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false],
55
);
56
$tenantA = assetUuid();
57
$tenantB = assetUuid();
58
$actor = assetUuid();
59
$pdo->prepare('INSERT INTO tenants (id,name,status,created_at) VALUES (?, ?, ?, UTC_TIMESTAMP()), (?, ?, ?, UTC_TIMESTAMP())')->execute([$tenantA,'Asset A','active',$tenantB,'Asset B','active']);
60
$reportService = new ReportService($pdo);
61
$draft = $reportService->create($tenantA, $actor, ['payload' => ['title' => 'Asset draft']]);
62
$draftId = (string) $draft['id'];
63
64
$tokens = new TokenService($config);
65
$ownerToken = $tokens->issueAccess(['sub' => $actor, 'tid' => $tenantA, 'role' => 'owner']);
66
$technicianToken = $tokens->issueAccess(['sub' => assetUuid(), 'tid' => $tenantA, 'role' => 'technician']);
67
$tenantBToken = $tokens->issueAccess(['sub' => assetUuid(), 'tid' => $tenantB, 'role' => 'owner']);
68
$kernel = new Kernel($config, new Logger('test'));
69
$cronPath = '/api/v1/internal/cleanup-assets';
70
$cronAuthorization = 'Basic ' . base64_encode('asset-cleanup-test:' . str_repeat('cleanup-contract-secret-', 2));
71
assetCheck($kernel->handle(new Request('GET', $cronPath, []))->status === 401, 'cleanup endpoint must reject missing basic credentials');
72
assetCheck($kernel->handle(new Request('GET', $cronPath, ['authorization' => 'Basic ' . base64_encode('asset-cleanup-test:wrong')]))->status === 401, 'cleanup endpoint must reject invalid basic credentials');
73
74
$png = "\x89PNG\r\n\x1A\n" . str_repeat('asset-data-', 40);
75
$photoId = assetUuid();
76
$photoMetadata = [
77
'assetId' => $photoId,
78
'reportId' => $draftId,
79
'reportVersion' => 1,
80
'kind' => 'photo',
81
'fileName' => 'beleg.png',
82
'mimeType' => 'image/png',
83
'byteSize' => strlen($png),
84
'sha256' => hash('sha256', $png),
85
];
86
$forbidden = startAsset($kernel, $technicianToken, $photoMetadata);
87
assetCheck($forbidden->status === 403, 'technician must not start asset uploads');
88
$started = startAsset($kernel, $ownerToken, $photoMetadata);
89
assetCheck($started->status === 201 && $started->body['data']['offset'] === 0, 'owner must start upload at offset zero');
90
$uploadId = (string) $started->body['data']['uploadId'];
91
$first = substr($png, 0, 80);
92
$second = substr($png, 80);
93
$progress = putAsset($kernel, $ownerToken, $uploadId, 0, $first);
94
assetCheck($progress->status === 200 && $progress->body['data']['offset'] === 80, 'first chunk must advance offset');
95
$replayedChunk = putAsset($kernel, $ownerToken, $uploadId, 0, $first);
96
assetCheck($replayedChunk->status === 200 && $replayedChunk->body['data']['offset'] === 80, 'identical repeated chunk must be idempotent');
97
$resumed = startAsset($kernel, $ownerToken, $photoMetadata);
98
assetCheck($resumed->body['data']['uploadId'] === $uploadId && $resumed->body['data']['offset'] === 80, 'start must resume existing upload');
99
$completed = putAsset($kernel, $ownerToken, $uploadId, 80, $second);
100
assetCheck($completed->status === 200 && $completed->body['data']['complete'] === true, 'final chunk must complete asset');
101
$replayedStart = startAsset($kernel, $ownerToken, $photoMetadata);
102
assetCheck($replayedStart->body['data']['complete'] === true && $replayedStart->body['data']['asset']['sha256'] === hash('sha256', $png), 'completed upload initialization must be idempotent');
103
104
$download = $kernel->handle(new Request('GET', '/api/v1/report-assets/' . $photoId . '/content', ['authorization' => 'Bearer ' . $technicianToken]));
105
assetCheck($download->status === 200 && $download->binaryBody === $png, 'authorized read must return integrity-checked bytes');
106
assetCheck(($download->headers['ETag'] ?? '') === '"' . hash('sha256', $png) . '"', 'download must expose immutable checksum');
107
$isolated = $kernel->handle(new Request('GET', '/api/v1/report-assets/' . $photoId, ['authorization' => 'Bearer ' . $tenantBToken]));
108
assetCheck($isolated->status === 404, 'other tenant must not observe asset metadata');
109
$reused = $photoMetadata;
110
$reused['fileName'] = 'different.png';
111
assetCheck(startAsset($kernel, $ownerToken, $reused)->status === 409, 'asset id cannot be rebound to different metadata');
112
113
$pdf = '%PDF-1.7 test document';
114
$fileId = assetUuid();
115
$fileMetadata = ['assetId'=>$fileId,'reportId'=>$draftId,'reportVersion'=>1,'kind'=>'file','fileName'=>'beleg.pdf','mimeType'=>'application/pdf','byteSize'=>strlen($pdf),'sha256'=>hash('sha256',$pdf)];
116
$fileStart = startAsset($kernel,$ownerToken,$fileMetadata);
117
putAsset($kernel,$ownerToken,(string)$fileStart->body['data']['uploadId'],0,$pdf);
118
$deleted = $kernel->handle(new Request('DELETE','/api/v1/report-assets/'.$fileId,['authorization'=>'Bearer '.$ownerToken]));
119
assetCheck($deleted->status === 204, 'draft asset must be deletable');
120
assetCheck($kernel->handle(new Request('GET','/api/v1/report-assets/'.$fileId,['authorization'=>'Bearer '.$ownerToken]))->status === 404, 'deleted asset must no longer be readable');
121
$deletedRow = $pdo->query('SELECT storage_key,content_deleted_at FROM report_assets WHERE id=' . $pdo->quote($fileId))->fetch();
122
assetCheck(is_array($deletedRow) && $deletedRow['content_deleted_at'] !== null && !is_file($storage . '/' . $deletedRow['storage_key']), 'draft deletion must purge bytes and record physical deletion');
123
$deleteAudit = $pdo->query('SELECT event_type FROM report_asset_audit_events WHERE asset_id=' . $pdo->quote($fileId) . ' ORDER BY created_at,id')->fetchAll(PDO::FETCH_COLUMN);
124
sort($deleteAudit);
125
assetCheck($deleteAudit === ['deleted','uploaded'], 'upload and delete must remain audited');
126
127
$signature = json_encode([[0.1,0.2],[0.2,0.3],[-1,-1],[0.3,0.4]], JSON_THROW_ON_ERROR);
128
$signatureId = assetUuid();
129
$finalized = $reportService->create($tenantA,$actor,['status'=>'finalized','payload'=>['isFinalized'=>true,'signatureAssetId'=>$signatureId,'signature'=>json_decode($signature,true,512,JSON_THROW_ON_ERROR)]]);
130
$signatureMetadata = ['assetId'=>$signatureId,'reportId'=>(string)$finalized['id'],'reportVersion'=>1,'kind'=>'signature','fileName'=>'unterschrift.machweis-signature.json','mimeType'=>'application/vnd.machweis.signature+json','byteSize'=>strlen($signature),'sha256'=>hash('sha256',$signature)];
131
$signatureStart = startAsset($kernel,$ownerToken,$signatureMetadata);
132
assetCheck(putAsset($kernel,$ownerToken,(string)$signatureStart->body['data']['uploadId'],0,$signature)->body['data']['complete'] === true, 'signature must upload against immutable report version');
133
$signatureDelete = $kernel->handle(new Request('DELETE','/api/v1/report-assets/'.$signatureId,['authorization'=>'Bearer '.$ownerToken]));
134
assetCheck($signatureDelete->status === 409 && $signatureDelete->body['error']['code'] === 'finalized_asset_immutable', 'finalized signature must be immutable');
135
136
$bad = 'not a png';
137
$badMetadata = ['assetId'=>assetUuid(),'reportId'=>$draftId,'reportVersion'=>1,'kind'=>'photo','fileName'=>'fake.png','mimeType'=>'image/png','byteSize'=>strlen($bad),'sha256'=>hash('sha256',$bad)];
138
$badStart = startAsset($kernel,$ownerToken,$badMetadata);
139
$badResult = putAsset($kernel,$ownerToken,(string)$badStart->body['data']['uploadId'],0,$bad);
140
assetCheck($badResult->status === 422 && $badResult->body['error']['code'] === 'integrity_failed', 'declared MIME type must match file signature');
141
142
$expiredMetadata = $photoMetadata;
143
$expiredMetadata['assetId'] = assetUuid();
144
$expiredMetadata['fileName'] = 'expired.png';
145
$expiredStart = startAsset($kernel,$ownerToken,$expiredMetadata);
146
$expiredId = (string) $expiredStart->body['data']['uploadId'];
147
$pdo->prepare('UPDATE report_asset_uploads SET expires_at=DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 MINUTE) WHERE id=?')->execute([$expiredId]);
148
$cleanupResponse = $kernel->handle(new Request('GET', $cronPath, ['authorization' => $cronAuthorization]));
149
assetCheck($cleanupResponse->status === 200 && $cleanupResponse->body['data']['expiredUploadsRemoved'] === 1 && (int)$pdo->query('SELECT COUNT(*) FROM report_asset_uploads WHERE id=' . $pdo->quote($expiredId))->fetchColumn() === 0, 'authenticated cleanup endpoint must remove expired resumable sessions');
150
assetCheck((int)$pdo->query('SELECT COUNT(*) FROM asset_cleanup_runs')->fetchColumn() === 1, 'authenticated cleanup endpoint must persist a successful run marker');
151
152
$events = (int) $pdo->query('SELECT COUNT(*) FROM report_asset_audit_events WHERE asset_id=' . $pdo->quote($photoId) . " AND event_type='uploaded' AND sha256=" . $pdo->quote(hash('sha256',$png)))->fetchColumn();
153
assetCheck($events === 1, 'successful asset must have one checksum-bound audit event');
154
155
fwrite(STDOUT, "report_assets_contract: OK\n");
156