-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathDownloaderTest.cs
500 lines (444 loc) · 21.5 KB
/
DownloaderTest.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Coder.Desktop.Vpn.Service;
using Microsoft.Extensions.Logging.Abstractions;
namespace Coder.Desktop.Tests.Vpn.Service;
public class TestDownloadValidator : IDownloadValidator
{
private readonly Exception _e;
public TestDownloadValidator(Exception e)
{
_e = e;
}
public Task ValidateAsync(string path, CancellationToken ct = default)
{
throw _e;
}
}
[TestFixture]
public class AuthenticodeDownloadValidatorTest
{
[Test(Description = "Test an unsigned binary")]
[CancelAfter(30_000)]
public void Unsigned(CancellationToken ct)
{
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe");
var ex = Assert.ThrowsAsync<Exception>(() =>
AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct));
Assert.That(ex.Message,
Does.Contain(
"File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=None"));
}
[Test(Description = "Test an untrusted binary")]
[CancelAfter(30_000)]
public void Untrusted(CancellationToken ct)
{
var testBinaryPath =
Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-self-signed.exe");
var ex = Assert.ThrowsAsync<Exception>(() =>
AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct));
Assert.That(ex.Message,
Does.Contain(
"File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=UntrustedRoot"));
}
[Test(Description = "Test an binary with a detached signature (catalog file)")]
[CancelAfter(30_000)]
public void DifferentCertTrusted(CancellationToken ct)
{
// rundll32.exe uses a catalog file for its signature.
var ex = Assert.ThrowsAsync<Exception>(() =>
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\rundll32.exe", ct));
Assert.That(ex.Message,
Does.Contain("File is not signed with an embedded Authenticode signature: Kind=Catalog"));
}
[Test(Description = "Test a binary signed by a non-EV certificate")]
[CancelAfter(30_000)]
public void NonEvCert(CancellationToken ct)
{
// dotnet.exe is signed by .NET. During tests we can be pretty sure
// this is installed.
var ex = Assert.ThrowsAsync<Exception>(() =>
AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Program Files\dotnet\dotnet.exe", ct));
Assert.That(ex.Message,
Does.Contain(
"File is not signed with an Extended Validation Code Signing certificate"));
}
[Test(Description = "Test a binary signed by an EV certificate with a different name")]
[CancelAfter(30_000)]
public void EvDifferentCertName(CancellationToken ct)
{
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
"hello-versioned-signed.exe");
var ex = Assert.ThrowsAsync<Exception>(() =>
new AuthenticodeDownloadValidator("Acme Corporation").ValidateAsync(testBinaryPath, ct));
Assert.That(ex.Message,
Does.Contain(
"File is signed by an unexpected certificate: ExpectedName='Acme Corporation', ActualName='Coder Technologies Inc.'"));
}
[Test(Description = "Test a binary signed by Coder's certificate")]
[CancelAfter(30_000)]
public async Task CoderSigned(CancellationToken ct)
{
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
"hello-versioned-signed.exe");
await AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct);
}
[Test(Description = "Test if the EV check works")]
public void IsEvCert()
{
// To avoid potential API misuse the function is private.
var method = typeof(AuthenticodeDownloadValidator).GetMethod("IsExtendedValidationCertificate",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(method, Is.Not.Null, "Could not find IsExtendedValidationCertificate method");
// Call it with various certificates.
var certs = new List<(string, bool)>
{
// EV:
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "coder-ev.crt"), true),
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "google-llc-ev.crt"), true),
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "self-signed-ev.crt"), true),
// Not EV:
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "mozilla-corporation.crt"), false),
(Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "self-signed.crt"), false),
};
foreach (var (certPath, isEv) in certs)
{
var x509Cert = new X509Certificate2(certPath);
var result = (bool?)method!.Invoke(null, [x509Cert]);
Assert.That(result, Is.Not.Null,
$"IsExtendedValidationCertificate returned null for {Path.GetFileName(certPath)}");
Assert.That(result, Is.EqualTo(isEv),
$"IsExtendedValidationCertificate returned wrong result for {Path.GetFileName(certPath)}");
}
}
}
[TestFixture]
public class AssemblyVersionDownloadValidatorTest
{
[Test(Description = "No version on binary")]
[CancelAfter(30_000)]
public void NoVersion(CancellationToken ct)
{
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe");
var ex = Assert.ThrowsAsync<Exception>(() =>
new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct));
Assert.That(ex.Message, Does.Contain("File ProductVersion is empty or null"));
}
[Test(Description = "Invalid version on binary")]
[CancelAfter(30_000)]
public void InvalidVersion(CancellationToken ct)
{
var testBinaryPath =
Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-invalid-version.exe");
var ex = Assert.ThrowsAsync<Exception>(() =>
new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct));
Assert.That(ex.Message, Does.Contain("File ProductVersion '1-2-3-4' is not a valid version string"));
}
[Test(Description = "Version mismatch with full version check")]
[CancelAfter(30_000)]
public void VersionMismatchFull(CancellationToken ct)
{
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
"hello-versioned-signed.exe");
// Try changing each version component one at a time
var expectedVersions = new[] { 1, 2, 3, 4 };
for (var i = 0; i < 4; i++)
{
var testVersions = (int[])expectedVersions.Clone();
testVersions[i]++; // Increment this component to make it wrong
var ex = Assert.ThrowsAsync<Exception>(() =>
new AssemblyVersionDownloadValidator(
testVersions[0], testVersions[1], testVersions[2], testVersions[3]
).ValidateAsync(testBinaryPath, ct));
Assert.That(ex.Message, Does.Contain(
$"File ProductVersion does not match expected version: Actual='1.2.3.4', Expected='{string.Join(".", testVersions)}'"));
}
}
[Test(Description = "Version match with and without partial version check")]
[CancelAfter(30_000)]
public async Task VersionMatch(CancellationToken ct)
{
var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata",
"hello-versioned-signed.exe");
// Test with just major.minor
await new AssemblyVersionDownloadValidator(1, 2).ValidateAsync(testBinaryPath, ct);
// Test with major.minor.patch
await new AssemblyVersionDownloadValidator(1, 2, 3).ValidateAsync(testBinaryPath, ct);
// Test with major.minor.patch.build
await new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct);
}
}
[TestFixture]
public class CombinationDownloadValidatorTest
{
[Test(Description = "All validators pass")]
[CancelAfter(30_000)]
public async Task AllPass(CancellationToken ct)
{
var validator = new CombinationDownloadValidator(
NullDownloadValidator.Instance,
NullDownloadValidator.Instance
);
await validator.ValidateAsync("test", ct);
}
[Test(Description = "A validator fails")]
[CancelAfter(30_000)]
public void Fail(CancellationToken ct)
{
var validator = new CombinationDownloadValidator(
NullDownloadValidator.Instance,
new TestDownloadValidator(new Exception("test exception"))
);
var ex = Assert.ThrowsAsync<Exception>(() => validator.ValidateAsync("test", ct));
Assert.That(ex.Message, Is.EqualTo("test exception"));
}
}
[TestFixture]
public class DownloaderTest
{
// FYI, SetUp and TearDown get called before and after each test.
[SetUp]
public void Setup()
{
_tempDir = Path.Combine(Path.GetTempPath(), "Coder.Desktop.Tests.Vpn.Service_" + Path.GetRandomFileName());
Directory.CreateDirectory(_tempDir);
}
[TearDown]
public void TearDown()
{
Directory.Delete(_tempDir, true);
}
private string _tempDir;
private static TestHttpServer EchoServer()
{
// Create webserver that replies to `/xyz` with a test file containing
// `xyz`.
return new TestHttpServer(async ctx =>
{
// Get the path without the leading slash.
var path = ctx.Request.Url!.AbsolutePath[1..];
var pathBytes = Encoding.UTF8.GetBytes(path);
// If the client sends an If-None-Match header with the correct ETag,
// return 304 Not Modified.
var etag = "\"" + Convert.ToHexString(SHA1.HashData(pathBytes)).ToLower() + "\"";
if (ctx.Request.Headers["If-None-Match"] == etag)
{
ctx.Response.StatusCode = 304;
return;
}
ctx.Response.StatusCode = 200;
ctx.Response.Headers.Add("ETag", etag);
ctx.Response.ContentType = "text/plain";
ctx.Response.ContentLength64 = pathBytes.Length;
await ctx.Response.OutputStream.WriteAsync(pathBytes);
});
}
[Test(Description = "Perform a download")]
[CancelAfter(30_000)]
public async Task Download(CancellationToken ct)
{
using var httpServer = EchoServer();
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
Assert.That(dlTask.TotalBytes, Is.EqualTo(4));
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
Assert.That(dlTask.Progress, Is.EqualTo(1));
Assert.That(dlTask.IsCompleted, Is.True);
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
}
[Test(Description = "Perform 2 downloads with the same destination")]
[CancelAfter(30_000)]
public async Task DownloadSameDest(CancellationToken ct)
{
using var httpServer = EchoServer();
var url0 = new Uri(httpServer.BaseUrl + "/test0");
var url1 = new Uri(httpServer.BaseUrl + "/test1");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
var startTask0 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath,
NullDownloadValidator.Instance, ct);
var startTask1 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url1), destPath,
NullDownloadValidator.Instance, ct);
var dlTask0 = await startTask0;
await dlTask0.Task;
Assert.That(dlTask0.TotalBytes, Is.EqualTo(5));
Assert.That(dlTask0.BytesRead, Is.EqualTo(5));
Assert.That(dlTask0.Progress, Is.EqualTo(1));
Assert.That(dlTask0.IsCompleted, Is.True);
var dlTask1 = await startTask1;
await dlTask1.Task;
Assert.That(dlTask1.TotalBytes, Is.EqualTo(5));
Assert.That(dlTask1.BytesRead, Is.EqualTo(5));
Assert.That(dlTask1.Progress, Is.EqualTo(1));
Assert.That(dlTask1.IsCompleted, Is.True);
}
[Test(Description = "Download with custom headers")]
[CancelAfter(30_000)]
public async Task WithHeaders(CancellationToken ct)
{
using var httpServer = new TestHttpServer(ctx =>
{
Assert.That(ctx.Request.Headers["X-Custom-Header"], Is.EqualTo("custom-value"));
ctx.Response.StatusCode = 200;
});
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Add("X-Custom-Header", "custom-value");
var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
await dlTask.Task;
}
[Test(Description = "Perform a download against an existing identical file")]
[CancelAfter(30_000)]
public async Task DownloadExisting(CancellationToken ct)
{
using var httpServer = EchoServer();
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
// Create the destination file with a very old timestamp.
await File.WriteAllTextAsync(destPath, "test", ct);
File.SetLastWriteTime(destPath, DateTime.Now - TimeSpan.FromDays(365));
var manager = new Downloader(NullLogger<Downloader>.Instance);
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
Assert.That(dlTask.BytesRead, Is.Zero);
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Assert.That(File.GetLastWriteTime(destPath), Is.LessThan(DateTime.Now - TimeSpan.FromDays(1)));
}
[Test(Description = "Perform a download against an existing file with different content")]
[CancelAfter(30_000)]
public async Task DownloadExistingDifferentContent(CancellationToken ct)
{
using var httpServer = EchoServer();
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
// Create the destination file with a very old timestamp.
await File.WriteAllTextAsync(destPath, "TEST", ct);
File.SetLastWriteTime(destPath, DateTime.Now - TimeSpan.FromDays(365));
var manager = new Downloader(NullLogger<Downloader>.Instance);
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Assert.That(File.GetLastWriteTime(destPath), Is.GreaterThan(DateTime.Now - TimeSpan.FromDays(1)));
}
[Test(Description = "Unexpected response code from server")]
[CancelAfter(30_000)]
public async Task UnexpectedResponseCode(CancellationToken ct)
{
using var httpServer = new TestHttpServer(ctx => { ctx.Response.StatusCode = 404; });
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
// The "inner" Task should fail.
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
var ex = Assert.ThrowsAsync<HttpRequestException>(async () => await dlTask.Task);
Assert.That(ex.Message, Does.Contain("404"));
}
// TODO: It would be nice to have a test that tests mismatched
// Content-Length, but it seems HttpListener doesn't allow that.
[Test(Description = "Mismatched ETag")]
[CancelAfter(30_000)]
public async Task MismatchedETag(CancellationToken ct)
{
using var httpServer = new TestHttpServer(ctx =>
{
ctx.Response.StatusCode = 200;
ctx.Response.Headers.Add("ETag", "\"beef\"");
});
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
// The "inner" Task should fail.
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
var ex = Assert.ThrowsAsync<HttpRequestException>(async () => await dlTask.Task);
Assert.That(ex.Message, Does.Contain("ETag does not match SHA1 hash of downloaded file").And.Contains("beef"));
}
[Test(Description = "Timeout waiting for existing download")]
[CancelAfter(30_000)]
public async Task CancelledWaitingForOther(CancellationToken ct)
{
var testCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
using var httpServer = new TestHttpServer(async _ =>
{
await Task.Delay(TimeSpan.FromSeconds(5), testCts.Token);
});
var url0 = new Uri(httpServer.BaseUrl + "/test0");
var url1 = new Uri(httpServer.BaseUrl + "/test1");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
// first outer task succeeds, getting download started
var dlTask0 = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath,
NullDownloadValidator.Instance, testCts.Token);
// The second request fails if the timeout is short
var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token;
Assert.ThrowsAsync<TaskCanceledException>(async () => await manager.StartDownloadAsync(
new HttpRequestMessage(HttpMethod.Get, url1), destPath,
NullDownloadValidator.Instance, smallerCt));
await testCts.CancelAsync();
}
[Test(Description = "Timeout on response body")]
[CancelAfter(30_000)]
public async Task CancelledInner(CancellationToken ct)
{
using var httpServer = new TestHttpServer(async ctx =>
{
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
await ctx.Response.OutputStream.FlushAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
});
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
// The "inner" Task should fail.
var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token;
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, smallerCt);
var ex = Assert.ThrowsAsync<TaskCanceledException>(async () => await dlTask.Task);
Assert.That(ex.CancellationToken, Is.EqualTo(smallerCt));
}
[Test(Description = "Validation failure")]
[CancelAfter(30_000)]
public async Task ValidationFailure(CancellationToken ct)
{
using var httpServer = EchoServer();
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
new TestDownloadValidator(new Exception("test exception")), ct);
var ex = Assert.ThrowsAsync<HttpRequestException>(async () => await dlTask.Task);
Assert.That(ex.Message, Does.Contain("Downloaded file failed validation"));
Assert.That(ex.InnerException, Is.Not.Null);
Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception"));
}
[Test(Description = "Validation failure on existing file")]
[CancelAfter(30_000)]
public async Task ValidationFailureExistingFile(CancellationToken ct)
{
using var httpServer = EchoServer();
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
await File.WriteAllTextAsync(destPath, "test", ct);
var manager = new Downloader(NullLogger<Downloader>.Instance);
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
new TestDownloadValidator(new Exception("test exception")), ct);
// The "inner" Task should fail.
var ex = Assert.ThrowsAsync<Exception>(async () => { await dlTask.Task; });
Assert.That(ex.Message, Does.Contain("Existing file failed validation"));
Assert.That(ex.InnerException, Is.Not.Null);
Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception"));
}
}