SignedQueryMiddlewareTest::testThrowIfNoKeys()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 3
c 2
b 0
f 0
dl 0
loc 5
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace EcodevTests\Felix\Middleware;
6
7
use Cake\Chronos\Chronos;
8
use Ecodev\Felix\Middleware\SignedQueryMiddleware;
9
use Laminas\Diactoros\CallbackStream;
10
use Laminas\Diactoros\Response;
11
use Laminas\Diactoros\ServerRequest;
12
use PHPUnit\Framework\TestCase;
13
use Psr\Http\Message\ServerRequestInterface;
14
use Psr\Http\Server\RequestHandlerInterface;
15
16
class SignedQueryMiddlewareTest extends TestCase
17
{
18
    protected function setUp(): void
19
    {
20
        Chronos::setTestNow((new Chronos('2020-01-02T12:30', 'Europe/Zurich')));
21
    }
22
23
    protected function tearDown(): void
24
    {
25
        Chronos::setTestNow();
26
    }
27
28
    /**
29
     * @dataProvider dataProviderQuery
30
     */
31
    public function testRequiredSignedQuery(array $keys, string $body, null|array $parsedBody, string $signature, string $expectExceptionMessage = '', string $ip = ''): void
32
    {
33
        $this->process($keys, true, $ip, $body, $parsedBody, $signature, $expectExceptionMessage);
34
    }
35
36
    /**
37
     * @dataProvider dataProviderQuery
38
     */
39
    public function testNonRequiredSignedQuery(array $keys, string $body, null|array $parsedBody, string $signature): void
40
    {
41
        $this->process($keys, false, '', $body, $parsedBody, $signature, '');
42
    }
43
44
    public function testThrowIfNoKeys(): void
45
    {
46
        $this->expectExceptionMessage('Signed queries are required, but no keys are configured');
47
        $this->expectExceptionCode(0);
48
        new SignedQueryMiddleware([], []);
49
    }
50
51
    private function process(array $keys, bool $required, string $ip, string $body, null|array $parsedBody, string $signature, string $expectExceptionMessage): void
52
    {
53
        $request = new ServerRequest(['REMOTE_ADDR' => $ip]);
54
        $request = $request->withBody(new CallbackStream(fn () => $body))->withParsedBody($parsedBody);
55
56
        if ($signature) {
57
            $request = $request->withHeader('X-Signature', $signature);
58
        }
59
60
        $handler = $this->createMock(RequestHandlerInterface::class);
61
        $handler->expects($expectExceptionMessage ? self::never() : self::once())
62
            ->method('handle')
63
            ->willReturnCallback(function (ServerRequestInterface $incomingRequest) use ($body) {
64
                self::assertSame($body, $incomingRequest->getBody()->getContents(), 'the original body content is still available for next middlewares');
65
66
                return new Response();
67
            });
68
69
        $middleware = new SignedQueryMiddleware($keys, ['1.2.3.4', '2a01:198:603:0::/65'], $required);
70
71
        if ($expectExceptionMessage) {
72
            $this->expectExceptionMessage($expectExceptionMessage);
73
            $this->expectExceptionCode(403);
74
        }
75
76
        $middleware->process($request, $handler);
77
    }
78
79
    public static function dataProviderQuery(): iterable
80
    {
81
        $key1 = 'my-secret-1';
82
        $key2 = 'my-secret-2';
83
84
        yield 'simple' => [
85
            [$key1],
86
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
87
            null,
88
            'v1.1577964600.a4d664cd3d9903e4fecf6f9f671ad953586a7faeb16e67c306fd9f29999dfdd7',
89
        ];
90
91
        yield 'simple but wrong key' => [
92
            [$key2],
93
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
94
            null,
95
            'v1.1577964600.a4d664cd3d9903e4fecf6f9f671ad953586a7faeb16e67c306fd9f29999dfdd7',
96
            'Invalid signed query',
97
        ];
98
99
        yield 'simple with all keys' => [
100
            [$key2, $key1],
101
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
102
            null,
103
            'v1.1577964600.a4d664cd3d9903e4fecf6f9f671ad953586a7faeb16e67c306fd9f29999dfdd7',
104
        ];
105
106
        yield 'simple but slightly in the past' => [
107
            [$key1],
108
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
109
            null,
110
            'v1.1577951100.7d3b639703584e3ea4c68b30a37b56bcf94d19ccdc11c7f05a737c4e7e663a6c',
111
        ];
112
113
        yield 'simple but too much in the past' => [
114
            [$key1],
115
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
116
            null,
117
            'v1.1577951099.' . str_repeat('a', 64),
118
            'Signed query is expired',
119
        ];
120
121
        yield 'simple but slightly in the future' => [
122
            [$key1],
123
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
124
            null,
125
            'v1.1577978100.b6fb50cd1aa3974ec9df0c320bf32ff58a28f6fc2040aa13e529a7ef57212e49',
126
        ];
127
128
        yield 'simple but too much in the future' => [
129
            [$key1],
130
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
131
            null,
132
            'v1.1577978101.' . str_repeat('a', 64),
133
            'Signed query is expired',
134
        ];
135
136
        yield 'batching' => [
137
            [$key1],
138
            '[{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }},{"operationName":"Configuration","variables":{"key":"announcement-active"},"query":"query Configuration($key: String!) { configuration(key: $key)}"}]',
139
            null,
140
            'v1.1577964600.566fafed794d956d662662b0df3d88e5c0a1e52e19111c08cc122f64a54bd8ec',
141
142
        ];
143
144
        yield 'file upload' => [
145
            [$key1],
146
            '',
147
            [
148
                'operations' => '{"operationName":"CreateImage","variables":{"input":{"file":null}},"query":"mutation CreateImage($input: ImageInput!) { createImage(input: $input) { id }}"}',
149
                'map' => '{"1":["variables.input.file"]}',
150
            ],
151
            'v1.1577964600.69dd1f396016e284afb221966ae5e61323a23222f2ad2a5086e4ba2354f99e58',
152
        ];
153
154
        yield 'file upload will ignore map and uploaded file to sign' => [
155
            [$key1],
156
            '',
157
            [
158
                'operations' => '{"operationName":"CreateImage","variables":{"input":{"file":null}},"query":"mutation CreateImage($input: ImageInput!) { createImage(input: $input) { id }}"}',
159
                'map' => 'different map',
160
                1 => 'fake file',
161
            ],
162
            'v1.1577964600.69dd1f396016e284afb221966ae5e61323a23222f2ad2a5086e4ba2354f99e58',
163
        ];
164
165
        yield 'no header' => [
166
            [$key1],
167
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
168
            null,
169
            '',
170
            'Missing `X-Signature` HTTP header in signed query',
171
        ];
172
173
        yield 'invalid header' => [
174
            [$key1],
175
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
176
            null,
177
            'foo',
178
            'Invalid `X-Signature` HTTP header in signed query',
179
        ];
180
181
        yield 'no graphql operations with invalid signature is rejected' => [
182
            [$key1],
183
            '',
184
            null,
185
            'v1.1577964600.' . str_repeat('a', 64),
186
            'Invalid signed query',
187
        ];
188
189
        yield 'no graphql operations with correct signature is OK (but will be rejected later by GraphQL own validation mechanism)' => [
190
            [$key1],
191
            '',
192
            null,
193
            'v1.1577964600.ff8a9f2bc8090207b824d88251ed8e9d39434607d86e0f0b2837c597d6642c26', //,. str_repeat('a', 64),
194
            '',
195
        ];
196
197
        yield 'no header, but allowed IPv4' => [
198
            [$key1],
199
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
200
            null,
201
            '',
202
            '',
203
            '1.2.3.4',
204
        ];
205
206
        yield 'simple but wrong key will still error even if IP is allowed, because we want to be able to test signature even when allowed' => [
207
            [$key2],
208
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
209
            null,
210
            'v1.1577964600.a4d664cd3d9903e4fecf6f9f671ad953586a7faeb16e67c306fd9f29999dfdd7',
211
            'Invalid signed query',
212
            '1.2.3.4',
213
        ];
214
215
        yield 'no header, even GoogleBot is rejected, because GoogleBot should not forge new requests but only (re)play existing ones' => [
216
            [$key1],
217
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
218
            null,
219
            '',
220
            'Missing `X-Signature` HTTP header in signed query',
221
            '66.249.70.134',
222
        ];
223
224
        yield 'too much in the past, but GoogleBot is allowed to replay old requests' => [
225
            [$key1],
226
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
227
            null,
228
            'v1.1577951099.20177a7face4e05a75c4b2e41bc97a8225f420f5b7bb1709dd5499821dba0807',
229
            '',
230
            '66.249.70.134',
231
        ];
232
233
        yield 'too much in the past and invalid signature, even GoogleBot is rejected, because GoogleBot should not modify queries and their signatures' => [
234
            [$key1],
235
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
236
            null,
237
            'v1.1577951099' . str_repeat('a', 64),
238
            'Invalid `X-Signature` HTTP header in signed query',
239
            '66.249.70.134',
240
        ];
241
242
        yield 'no header, BingBot is allowed, because BingBot does not seem to include our custom header in his request' => [
243
            [$key1],
244
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
245
            null,
246
            '',
247
            '',
248
            '40.77.188.165',
249
        ];
250
251
        yield 'too much in the past, even BingBot is rejected, because BingBot should not have any header at all' => [
252
            [$key1],
253
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
254
            null,
255
            'v1.1577951099.20177a7face4e05a75c4b2e41bc97a8225f420f5b7bb1709dd5499821dba0807',
256
            'Signed query is expired',
257
            '40.77.188.165',
258
        ];
259
260
        yield 'too much in the past and invalid signature, even BingBot is rejected, because BingBot should not have any header at all' => [
261
            [$key1],
262
            '{"operationName":"CurrentUser","variables":{},"query":"query CurrentUser { viewer { id }}',
263
            null,
264
            'v1.1577951099' . str_repeat('a', 64),
265
            'Invalid `X-Signature` HTTP header in signed query',
266
            '40.77.188.165',
267
        ];
268
    }
269
}
270