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\ReportService;
9
use Machweis\Backend\Request;
10
use Machweis\Backend\SyncService;
11
use Machweis\Backend\SmtpMailer;
12
13
require dirname(__DIR__) . '/src/autoload.php';
14
15
function check(bool $condition, string $message): void
16
{
17
if (!$condition) throw new RuntimeException($message);
18
}
19
20
function testUuid(): string
21
{
22
$hex = bin2hex(random_bytes(16));
23
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20);
24
}
25
26
function expectDatabaseRejection(callable $operation, string $message): void
27
{
28
try {
29
$operation();
30
} catch (PDOException) {
31
return;
32
}
33
throw new RuntimeException($message);
34
}
35
36
$config = new Config(
37
'test', false, (string) getenv('DB_HOST'), (int) getenv('DB_PORT'), (string) getenv('DB_NAME'),
38
(string) getenv('DB_USER'), (string) getenv('DB_PASSWORD'), [], true, str_repeat('reports-contract-secret-', 2),
39
);
40
try { (new SmtpMailer($config))->sendPdf('customer@example.test', 'Test', 'Body', 'test.pdf', '%PDF-'); throw new RuntimeException('unconfigured SMTP must not send'); } catch (RuntimeException $error) { check($error->getMessage() === 'Transactional mail is not configured.', 'SMTP must reject missing credentials before connecting'); }
41
$pdo = new PDO(
42
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $config->dbHost, $config->dbPort, $config->dbName),
43
$config->dbUser,
44
$config->dbPassword,
45
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false],
46
);
47
48
$tenantA = testUuid();
49
$tenantB = testUuid();
50
$actor = testUuid();
51
$tenantInsert = $pdo->prepare('INSERT INTO tenants (id, name, status, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP()), (?, ?, ?, UTC_TIMESTAMP())');
52
$tenantInsert->execute([$tenantA, 'Test A', 'active', $tenantB, 'Test B', 'active']);
53
54
// Exercise the actual HTTP guard: owner/office/supervisor may write; technician may only read.
55
$tokens = new TokenService($config);
56
$kernel = new Kernel($config, new Logger('test'));
57
$tokensByRole = [];
58
foreach (['owner', 'office', 'supervisor', 'technician'] as $index => $role) {
59
$tokensByRole[$role] = $tokens->issueAccess(['sub' => sprintf('40000000-0000-4000-8000-%012d', $index + 1), 'tid' => $tenantA, 'role' => $role]);
60
}
61
foreach (['owner', 'office', 'supervisor'] as $role) {
62
$response = $kernel->handle(new Request('POST', '/api/v1/reports', ['authorization' => 'Bearer ' . $tokensByRole[$role]], json_encode(['payload' => ['role' => $role]], JSON_THROW_ON_ERROR)));
63
check($response->status === 201, "$role must be allowed to create reports");
64
}
65
$forbidden = $kernel->handle(new Request('POST', '/api/v1/reports', ['authorization' => 'Bearer ' . $tokensByRole['technician']], json_encode(['payload' => ['role' => 'technician']], JSON_THROW_ON_ERROR)));
66
check($forbidden->status === 403 && $forbidden->body['error']['code'] === 'forbidden', 'technician must not create reports');
67
68
$service = new ReportService($pdo);
69
$created = $service->create($tenantA, $actor, ['payload' => ['title' => 'original', 'items' => [1]]]);
70
$reportId = (string) $created['id'];
71
$service->addVersion($tenantA, $reportId, $actor, ['payload' => ['title' => 'second', 'items' => [1, 2]]]);
72
$versioned = $service->addVersion($tenantA, $reportId, $actor, ['payload' => ['title' => 'third', 'items' => [1, 2, 3]]]);
73
check((int) $versioned['current_version'] === 3, 'current version must advance to three');
74
check(array_column($versioned['versions'], 'version') === [3, 2, 1], 'all versions must be returned newest first');
75
check($versioned['versions'][2]['payload'] === ['title' => 'original', 'items' => [1]], 'first snapshot must remain unchanged');
76
check($versioned['versions'][1]['payload'] === ['title' => 'second', 'items' => [1, 2]], 'second snapshot must remain unchanged');
77
78
$searchable = $service->create($tenantA, $actor, ['payload' => ['customer' => 'Müller GmbH', 'site' => 'Nordhalle', 'activity' => 'Montage']]);
79
$searchResult = $service->list($tenantA, 'draft', 'Nordhalle', 1, 1);
80
check((int) $searchResult['total'] === 1, 'report search must return a tenant-scoped total');
81
check(count($searchResult['items']) === 1 && $searchResult['items'][0]['id'] === $searchable['id'], 'report search must match the current immutable payload');
82
check($service->list($tenantB, null, 'Nordhalle', 1, 10)['total'] === 0, 'report search must remain tenant isolated');
83
84
$technicianRead = $kernel->handle(new Request('GET', '/api/v1/reports/' . $reportId, ['authorization' => 'Bearer ' . $tokensByRole['technician']]));
85
check($technicianRead->status === 200, 'technician must be allowed to read reports');
86
$technicianVersion = $kernel->handle(new Request('POST', '/api/v1/reports/' . $reportId . '/versions', ['authorization' => 'Bearer ' . $tokensByRole['technician']], json_encode(['payload' => ['forbidden' => true]], JSON_THROW_ON_ERROR)));
87
check($technicianVersion->status === 403, 'technician must not add report versions');
88
89
try {
90
$service->one($tenantB, $reportId);
91
throw new RuntimeException('tenant isolation failed');
92
} catch (RuntimeException $error) {
93
if ($error->getMessage() === 'tenant isolation failed') throw $error;
94
}
95
96
$events = $pdo->query('SELECT event_type, version, actor_user_id FROM report_audit_events WHERE report_id = ' . $pdo->quote($reportId) . ' ORDER BY version')->fetchAll();
97
check(count($events) === 3, 'every immutable version must have one audit event');
98
check(array_column($events, 'event_type') === ['created', 'version_created', 'version_created'], 'audit event sequence must match version creation');
99
check(array_map('intval', array_column($events, 'version')) === [1, 2, 3], 'audit versions must be gap-free');
100
check(count(array_unique(array_column($events, 'actor_user_id'))) === 1 && $events[0]['actor_user_id'] === $actor, 'audit actor must be preserved');
101
102
expectDatabaseRejection(
103
fn () => $pdo->exec("INSERT INTO report_audit_events (id, report_id, tenant_id, event_type, version, actor_user_id, created_at) VALUES (" . $pdo->quote(testUuid()) . ', ' . $pdo->quote($reportId) . ", '$tenantA', 'orphan', 99, '$actor', UTC_TIMESTAMP())"),
104
'database must reject audit events without a matching version',
105
);
106
expectDatabaseRejection(
107
fn () => $pdo->exec("INSERT INTO report_audit_events (id, report_id, tenant_id, event_type, version, actor_user_id, created_at) VALUES (" . $pdo->quote(testUuid()) . ', ' . $pdo->quote($reportId) . ", '$tenantB', 'cross_tenant', 1, '$actor', UTC_TIMESTAMP())"),
108
'database must reject cross-tenant audit references',
109
);
110
111
// Mobile outbox contract: client IDs, stable idempotency and optimistic revisions.
112
$sync = new SyncService($pdo);
113
$mobileReportId = testUuid();
114
$createOperation = [
115
'operation_id' => testUuid(),
116
'idempotency_key' => 'report-create-' . $mobileReportId,
117
'entity' => 'reports',
118
'entity_id' => $mobileReportId,
119
'action' => 'create',
120
'base_revision' => 0,
121
'payload' => ['id' => $mobileReportId, 'activity' => 'Montage', 'version' => 1, 'isFinalized' => false],
122
];
123
$createdSync = $sync->push($tenantA, 'supervisor', [$createOperation], $actor);
124
check($createdSync['status'] === 200 && $createdSync['results'][0]['row_version'] === 1, 'mobile create must apply at revision one');
125
$replayedSync = $sync->push($tenantA, 'supervisor', [$createOperation], $actor);
126
check($replayedSync['results'][0]['replayed'] === true, 'identical report operation must be replayed');
127
check((int)$pdo->query('SELECT COUNT(*) FROM report_versions WHERE report_id = ' . $pdo->quote($mobileReportId))->fetchColumn() === 1, 'idempotent replay must not append a version');
128
129
$updateOperation = [
130
'operation_id' => testUuid(),
131
'idempotency_key' => 'report-update-' . $mobileReportId,
132
'entity' => 'reports',
133
'entity_id' => $mobileReportId,
134
'action' => 'update',
135
'base_revision' => 1,
136
'payload' => ['id' => $mobileReportId, 'activity' => 'Abnahme', 'version' => 2, 'isFinalized' => true],
137
];
138
$updatedSync = $sync->push($tenantA, 'supervisor', [$updateOperation], $actor);
139
check($updatedSync['results'][0]['row_version'] === 2, 'mobile update must advance the immutable revision');
140
$pdfResponse = $kernel->handle(new Request('POST', '/api/v1/reports/' . $mobileReportId . '/pdf', ['authorization' => 'Bearer ' . $tokensByRole['technician']]));
141
check($pdfResponse->status === 200, 'any report reader must be allowed to generate a finalized PDF');
142
check(($pdfResponse->headers['Content-Type'] ?? '') === 'application/pdf', 'finalized PDF must have its PDF content type');
143
check(str_starts_with((string) $pdfResponse->binaryBody, '%PDF-'), 'finalized PDF must be valid PDF bytes');
144
check(str_contains((string) $pdfResponse->binaryBody, 'MACHWEIS'), 'finalized PDF must contain the document heading');
145
$pdfAudit = $pdo->query('SELECT event_type, version FROM report_audit_events WHERE report_id = ' . $pdo->quote($mobileReportId) . " AND event_type = 'pdf_generated'")->fetch();
146
check(is_array($pdfAudit) && $pdfAudit['event_type'] === 'pdf_generated' && (int) $pdfAudit['version'] === 2, 'PDF generation must be audited against the immutable version');
147
$draftPdf = $kernel->handle(new Request('POST', '/api/v1/reports/' . $reportId . '/pdf', ['authorization' => 'Bearer ' . $tokensByRole['owner']]));
148
check($draftPdf->status === 422, 'draft reports must not receive a server PDF');
149
$staleOperation = $updateOperation;
150
$staleOperation['operation_id'] = testUuid();
151
$staleOperation['idempotency_key'] = 'report-stale-' . $mobileReportId;
152
$stale = $sync->push($tenantA, 'supervisor', [$staleOperation], $actor);
153
check($stale['status'] === 409 && $stale['results'][0]['code'] === 'row_version_conflict', 'stale report base revision must conflict');
154
check($stale['results'][0]['current_row_version'] === 2, 'conflict must expose the current server revision');
155
$mobileVersions = $pdo->query('SELECT version, payload_json FROM report_versions WHERE report_id = ' . $pdo->quote($mobileReportId) . ' ORDER BY version')->fetchAll();
156
check(count($mobileVersions) === 2, 'conflict and replay must not append report versions');
157
check(json_decode($mobileVersions[0]['payload_json'], true, 512, JSON_THROW_ON_ERROR)['activity'] === 'Montage', 'first mobile snapshot must remain immutable');
158
$mobileAudit = $pdo->query('SELECT event_type, version, actor_user_id FROM report_audit_events WHERE report_id = ' . $pdo->quote($mobileReportId) . " AND event_type <> 'pdf_generated' ORDER BY version")->fetchAll();
159
check(array_column($mobileAudit, 'event_type') === ['created', 'finalized'], 'mobile audit events must describe create and finalization');
160
check(count(array_unique(array_column($mobileAudit, 'actor_user_id'))) === 1 && $mobileAudit[0]['actor_user_id'] === $actor, 'mobile audit actor must be authenticated identity');
161
162
$technicianOperation = $createOperation;
163
$technicianOperation['operation_id'] = testUuid();
164
$technicianOperation['idempotency_key'] = 'report-technician-' . $mobileReportId;
165
$technicianOperation['entity_id'] = testUuid();
166
$technicianDenied = $sync->push($tenantA, 'technician', [$technicianOperation], $actor);
167
check($technicianDenied['status'] === 403 && $technicianDenied['results'][0]['code'] === 'forbidden', 'report sync must preserve write role policy');
168
169
fwrite(STDOUT, "reports_contract: OK\n");
170