1<?php
2
3declare(strict_types=1);
4
5namespace ZnoteX\Tests\Security;
6
7use PHPUnit\Framework\TestCase;
8
9require_once dirname(__DIR__, 2) . '/engine/function/plugin_settings.php';
10
11final class PluginSettingsTest extends TestCase
12{
13 private const TEST_PLUGIN = 'zztest_settings_plugin';
14
15 private function pluginDir(): string {
16 return ZNOTE_PLUGIN_DIR . '/' . self::TEST_PLUGIN;
17 }
18
19 protected function tearDown(): void
20 {
21 $dir = $this->pluginDir();
22 if (is_file($dir . '/settings.json')) {
23 unlink($dir . '/settings.json');
24 }
25 if (is_dir($dir)) {
26 rmdir($dir);
27 }
28 }
29
30 private function writeSchema(array $data): void
31 {
32 mkdir($this->pluginDir(), 0775, true);
33 file_put_contents($this->pluginDir() . '/settings.json', json_encode($data));
34 }
35
36 public function testHasIsFalseWithoutAFile(): void
37 {
38 $this->assertFalse(\znote_plugin_settings_has(self::TEST_PLUGIN));
39 }
40
41 public function testSchemaIsEmptyForAnUnknownPlugin(): void
42 {
43 $this->assertSame([], \znote_plugin_settings_schema('../../../etc/passwd'));
44 }
45
46 public function testSchemaParsesValidFields(): void
47 {
48 $this->writeSchema(['fields' => [
49 ['key' => 'api_key', 'type' => 'text', 'label' => 'API key', 'default' => ''],
50 ['key' => 'enabled', 'type' => 'bool', 'default' => '1'],
51 ['key' => 'mode', 'type' => 'select', 'default' => 'test', 'options' => ['test' => 'Test', 'live' => 'Live']],
52 ['key' => 'max_items', 'type' => 'int', 'default' => '10', 'min' => 1, 'max' => 100],
53 ]]);
54
55 $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN);
56
57 $this->assertTrue(\znote_plugin_settings_has(self::TEST_PLUGIN));
58 $this->assertSame(['api_key', 'enabled', 'mode', 'max_items'], array_keys($schema));
59 $this->assertSame('API key', $schema['api_key']['label']);
60 $this->assertSame(1, $schema['max_items']['min']);
61 $this->assertSame(100, $schema['max_items']['max']);
62 }
63
64 public function testSchemaDropsFieldsWithAnUnknownType(): void
65 {
66 $this->writeSchema(['fields' => [
67 ['key' => 'good', 'type' => 'text'],
68 ['key' => 'bad', 'type' => 'not_a_real_type'],
69 ]]);
70
71 $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN);
72
73 $this->assertArrayHasKey('good', $schema);
74 $this->assertArrayNotHasKey('bad', $schema);
75 }
76
77 public function testSchemaDropsFieldsWithAnInvalidKey(): void
78 {
79 $this->writeSchema(['fields' => [
80 ['key' => 'ok_key', 'type' => 'text'],
81 ['key' => 'has spaces', 'type' => 'text'],
82 ['key' => '../traversal', 'type' => 'text'],
83 ['key' => '', 'type' => 'text'],
84 ]]);
85
86 $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN);
87
88 $this->assertSame(['ok_key'], array_keys($schema));
89 }
90
91 public function testMalformedJsonYieldsAnEmptySchema(): void
92 {
93 mkdir($this->pluginDir(), 0775, true);
94 file_put_contents($this->pluginDir() . '/settings.json', '{not valid json');
95
96 $this->assertSame([], \znote_plugin_settings_schema(self::TEST_PLUGIN));
97 }
98
99 public function testStorageKeyMatchesTheExtensionApiNamespace(): void
100 {
101 $this->assertSame(
102 'plugin:my_plugin:setting:api_key',
103 \znote_plugin_settings_storage_key('my_plugin', 'api_key')
104 );
105 }
106
107 public function testSanitizeBoolCoercesAnyTruthyInputToOneOrZero(): void
108 {
109 $field = ['type' => 'bool'];
110 $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, '1'));
111 $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, 'on'));
112 $this->assertSame('0', \znote_plugin_settings_sanitize_field($field, null));
113 $this->assertSame('0', \znote_plugin_settings_sanitize_field($field, '0'));
114 }
115
116 public function testSanitizeIntRejectsNonNumericInput(): void
117 {
118 $field = ['type' => 'int', 'min' => null, 'max' => null];
119 $this->assertNull(\znote_plugin_settings_sanitize_field($field, 'not a number'));
120 $this->assertNull(\znote_plugin_settings_sanitize_field($field, '12; DROP TABLE accounts'));
121 }
122
123 public function testSanitizeIntClampsToMinAndMax(): void
124 {
125 $field = ['type' => 'int', 'min' => 1, 'max' => 10];
126 $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, '-5'));
127 $this->assertSame('10', \znote_plugin_settings_sanitize_field($field, '500'));
128 $this->assertSame('5', \znote_plugin_settings_sanitize_field($field, '5'));
129 }
130
131 public function testSanitizeSelectRejectsAValueOutsideItsOptions(): void
132 {
133 $field = ['type' => 'select', 'options' => ['a' => 'A', 'b' => 'B']];
134 $this->assertSame('a', \znote_plugin_settings_sanitize_field($field, 'a'));
135 $this->assertNull(\znote_plugin_settings_sanitize_field($field, 'injected'));
136 }
137
138 public function testSanitizeChecklistKeepsOnlyKnownOptions(): void
139 {
140 $field = ['type' => 'checklist', 'options' => ['a' => 'A', 'b' => 'B', 'c' => 'C']];
141 $this->assertSame('a,c', \znote_plugin_settings_sanitize_field($field, ['a', 'c', 'not_an_option']));
142 }
143
144 public function testSanitizeTextRejectsNonScalarInput(): void
145 {
146 $field = ['type' => 'text'];
147 $this->assertNull(\znote_plugin_settings_sanitize_field($field, ['array', 'not', 'scalar']));
148 $this->assertSame('hello', \znote_plugin_settings_sanitize_field($field, 'hello'));
149 }
150}
151