createToken(TokenKind::Admin, role: Role::Admin); $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :name', ['name' => 'moderate']); $created = $this->request( 'POST', '/api/v1/admin/consumers', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'fw-edge-01', 'policy_id' => $policyId]) ?: null, ); self::assertSame(201, $created->getStatusCode()); $body = $this->decode($created); self::assertSame('fw-edge-01', $body['name']); self::assertSame($policyId, $body['policy_id']); $list = $this->request('GET', '/api/v1/admin/consumers', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $list->getStatusCode()); $listBody = $this->decode($list); self::assertGreaterThan(0, $listBody['total']); } public function testCreateRejectsUnknownPolicy(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $resp = $this->request( 'POST', '/api/v1/admin/consumers', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'bogus', 'policy_id' => 99999]) ?: null, ); self::assertSame(400, $resp->getStatusCode()); self::assertArrayHasKey('policy_id', $this->decode($resp)['details']); } public function testPatchTogglesAuditEnabled(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :name', ['name' => 'moderate']); $created = $this->request( 'POST', '/api/v1/admin/consumers', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'fw-audit-toggle', 'policy_id' => $policyId]) ?: null, ); self::assertSame(201, $created->getStatusCode()); $body = $this->decode($created); self::assertTrue($body['audit_enabled']); $consumerId = (int) $body['id']; $patch = $this->request( 'PATCH', "/api/v1/admin/consumers/{$consumerId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['audit_enabled' => false]) ?: null, ); self::assertSame(200, $patch->getStatusCode()); self::assertFalse($this->decode($patch)['audit_enabled']); } public function testAuditEnabledToggleEmitsDedicatedAuditRow(): void { // SEC_REVIEW F41: an admin flipping `audit_enabled` for a consumer // must leave a flat alertable trail SOC tooling can match on with // `action = 'consumer.audit_toggled'` — without walking into the // metadata `changes` blob of the standard `consumer.updated` row. $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $policyId = (int) $this->db->fetchOne( 'SELECT id FROM policies WHERE name = :name', ['name' => 'moderate'], ); $created = $this->request( 'POST', '/api/v1/admin/consumers', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'fw-audit-signal', 'policy_id' => $policyId]) ?: null, ); $consumerId = (int) $this->decode($created)['id']; $this->request( 'PATCH', "/api/v1/admin/consumers/{$consumerId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['audit_enabled' => false]) ?: null, ); $rows = $this->db->fetchAllAssociative( "SELECT action, details_json FROM audit_log WHERE target_type = 'consumer' AND target_id = ? ORDER BY id", [(string) $consumerId], ); $actions = array_column($rows, 'action'); self::assertContains('consumer.updated', $actions); self::assertContains('consumer.audit_toggled', $actions); $toggleRow = null; foreach ($rows as $row) { if ($row['action'] === 'consumer.audit_toggled') { $toggleRow = $row; break; } } self::assertNotNull($toggleRow); $meta = json_decode((string) $toggleRow['details_json'], true); self::assertSame(true, $meta['from'] ?? null); self::assertSame(false, $meta['to'] ?? null); } public function testAuditEnabledNoOpDoesNotEmitDedicatedRow(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $policyId = (int) $this->db->fetchOne( 'SELECT id FROM policies WHERE name = :name', ['name' => 'moderate'], ); $created = $this->request( 'POST', '/api/v1/admin/consumers', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['name' => 'fw-noop', 'policy_id' => $policyId]) ?: null, ); $consumerId = (int) $this->decode($created)['id']; // PATCH `audit_enabled` to its current value (no-op) — must NOT // fire the toggle signal. $this->request( 'PATCH', "/api/v1/admin/consumers/{$consumerId}", ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['audit_enabled' => true]) ?: null, ); $actions = $this->db->fetchFirstColumn( "SELECT action FROM audit_log WHERE target_type = 'consumer' AND target_id = ?", [(string) $consumerId], ); self::assertNotContains('consumer.audit_toggled', $actions); } }