Skip to content
This repository was archived by the owner on Jan 23, 2025. It is now read-only.

Commit 4901266

Browse files
committed
#243. Generate a one-time password for new users (invite link).
1 parent 027389a commit 4901266

84 files changed

Lines changed: 2629 additions & 265 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ LICENSE
2525
README.md
2626
**/*.db
2727
**/*.db-shm
28-
**/*.db-wal
28+
**/*.db-wal
29+
**/dist

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ EXPOSE 80
5252
HEALTHCHECK --interval=5s --timeout=5s CMD wget http://localhost/health -q -O - > /dev/null 2>&1
5353

5454
ENV ASPNETCORE_ENVIRONMENT="Production"
55-
ENV ASPNETCORE_ConnectionStrings__DefaultConnection="Data Source=/data/pyro.db"
56-
ENV ASPNETCORE_Git__BasePath="/data"
5755
ENV ASPNETCORE_URLS="http://+:80"
56+
ENV ConnectionStrings__DefaultConnection="Data Source=/data/pyro.db"
57+
ENV Git__BasePath="/data"
5858

5959
RUN apk add --no-cache git
6060
RUN addgroup -S pyro && adduser -S pyro -G pyro

Pyro.Api/Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PackageVersion Include="JWT" Version="10.1.1" />
1212
<PackageVersion Include="JWT.Extensions.AspNetCore" Version="10.1.1" />
1313
<PackageVersion Include="LibGit2Sharp" Version="0.30.0" />
14+
<PackageVersion Include="MailKit" Version="4.8.0" />
1415
<PackageVersion Include="MediatR" Version="12.4.1" />
1516
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="8.0.10" />
1617
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.10" />
@@ -24,13 +25,15 @@
2425
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
2526
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
2627
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
28+
<PackageVersion Include="MimeKit" Version="4.8.0" />
2729
<PackageVersion Include="NewId" Version="4.0.1" />
2830
<PackageVersion Include="NSubstitute" Version="5.3.0" />
2931
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
3032
<PackageVersion Include="NUnit" Version="4.2.2" />
3133
<PackageVersion Include="NUnit.Analyzers" Version="4.3.0" />
3234
<PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" />
3335
<PackageVersion Include="Riok.Mapperly" Version="4.1.0" />
36+
<PackageVersion Include="SmtpServer" Version="10.0.1" />
3437
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
3538
<PackageVersion Include="Testcontainers" Version="4.0.0" />
3639
</ItemGroup>

Pyro.Api/Pyro.ApiTests/Api.cs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,74 @@
11
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
22
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.
33

4+
using System.Net;
5+
using System.Net.Sockets;
46
using DotNet.Testcontainers.Builders;
57
using DotNet.Testcontainers.Containers;
8+
using Pyro.ApiTests.Clients;
69

710
namespace Pyro.ApiTests;
811

912
[SetUpFixture]
10-
public class Api
13+
internal class Api
1114
{
15+
private static Smtp? smtp;
1216
private static IContainer? container;
1317
private static Uri? baseAddress;
1418

1519
[OneTimeSetUp]
1620
public async Task SetUp()
1721
{
22+
smtp = new Smtp();
23+
smtp.Start();
24+
1825
const int hostPort = 8080;
1926
const int containerPort = 80;
2027
var imageId = Environment.GetEnvironmentVariable("PYRO_IMAGE_ID") ??
2128
"pyro";
29+
var isInContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
2230

23-
container = new ContainerBuilder()
31+
var containerBuilder = new ContainerBuilder()
2432
.WithImage(imageId)
2533
.WithName("pyro")
2634
.WithPortBinding(hostPort, containerPort)
2735
.WithWaitStrategy(Wait.ForUnixContainer().UntilContainerIsHealthy())
28-
.Build();
36+
.WithEnvironment(new Dictionary<string, string>
37+
{
38+
["EmailService__Provider"] = "Smtp",
39+
["EmailService__Login"] = "test",
40+
["EmailService__Password"] = "1234",
41+
});
42+
43+
if (isInContainer)
44+
{
45+
var addresses = (await Dns.GetHostAddressesAsync(Dns.GetHostName()))
46+
.FirstOrDefault(x => x.AddressFamily == AddressFamily.InterNetwork);
47+
48+
containerBuilder = containerBuilder
49+
.WithEnvironment("EmailService__Host", $"smtp://{addresses}:25");
50+
}
51+
else
52+
{
53+
containerBuilder = containerBuilder
54+
.WithExtraHost("host.docker.internal", "host-gateway")
55+
.WithEnvironment("EmailService__Host", "smtp://host.docker.internal:25");
56+
}
57+
58+
container = containerBuilder.Build();
2959

3060
await container.StartAsync();
3161

32-
baseAddress = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"
62+
baseAddress = isInContainer
3363
? new Uri($"http://{container.IpAddress}:{containerPort}")
3464
: new Uri($"http://localhost:{hostPort}");
3565
}
3666

3767
[OneTimeTearDown]
3868
public async Task TearDown()
3969
{
70+
smtp?.Stop();
71+
4072
if (container is not null)
4173
{
4274
await container.StopAsync();
@@ -45,4 +77,6 @@ public async Task TearDown()
4577
}
4678

4779
public static Uri BaseAddress => baseAddress!;
80+
81+
public static Smtp Smtp => smtp!;
4882
}

Pyro.Api/Pyro.ApiTests/Clients/BaseClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ public async Task Login(string username, string password)
192192
}
193193

194194
public Task Login()
195-
=> Login("pyro", "pyro");
195+
=> Login("pyro@localhost.local", "pyro");
196196

197197
public async Task Logout()
198198
{

Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ public async Task LockUser(string login)
3535
public async Task UnlockUser(string login)
3636
=> await Post($"/api/users/{login}/unlock");
3737

38+
public async Task ActivateUser(ActivateUserRequest request)
39+
=> await Post($"/api/users/activate", request);
40+
3841
public async Task<IReadOnlyList<AccessTokenResponse>?> GetAccessTokens()
3942
=> await Get<IReadOnlyList<AccessTokenResponse>>("/api/users/access-tokens");
4043

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
2+
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.
3+
4+
using System.Buffers;
5+
using System.Collections.Concurrent;
6+
using System.Text.RegularExpressions;
7+
using SmtpServer;
8+
using SmtpServer.Authentication;
9+
using SmtpServer.ComponentModel;
10+
using SmtpServer.Protocol;
11+
using SmtpServer.Storage;
12+
13+
namespace Pyro.ApiTests.Clients;
14+
15+
internal sealed partial class Smtp : IMessageStore
16+
{
17+
private readonly SmtpServer.SmtpServer smtpServer;
18+
private readonly BlockingCollection<Message> messages;
19+
20+
public Smtp()
21+
{
22+
var options = new SmtpServerOptionsBuilder()
23+
.ServerName("localhost")
24+
.Endpoint(builder => builder
25+
.Port(25, false)
26+
.AllowUnsecureAuthentication())
27+
.Build();
28+
29+
var serviceProvider = new ServiceProvider();
30+
serviceProvider.Add(this);
31+
serviceProvider.Add(MailboxFilter.Default);
32+
serviceProvider.Add(UserAuthenticator.Default);
33+
34+
smtpServer = new SmtpServer.SmtpServer(options, serviceProvider);
35+
messages = [];
36+
}
37+
38+
public void Start()
39+
=> Task.Run(() => smtpServer.StartAsync(CancellationToken.None))
40+
.ContinueWith(
41+
task => Console.WriteLine(task.Exception),
42+
CancellationToken.None,
43+
TaskContinuationOptions.OnlyOnFaulted,
44+
TaskScheduler.Default);
45+
46+
public void Stop()
47+
=> smtpServer.Shutdown();
48+
49+
public async Task<SmtpResponse> SaveAsync(
50+
ISessionContext context,
51+
IMessageTransaction transaction,
52+
ReadOnlySequence<byte> buffer,
53+
CancellationToken cancellationToken)
54+
{
55+
await using var stream = new MemoryStream();
56+
57+
var position = buffer.GetPosition(0);
58+
while (buffer.TryGet(ref position, out var memory))
59+
await stream.WriteAsync(memory, cancellationToken);
60+
61+
stream.Position = 0;
62+
63+
var message = await MimeKit.MimeMessage.LoadAsync(stream, cancellationToken);
64+
messages.Add(
65+
new Message
66+
{
67+
From = message.From.Mailboxes.First().Address,
68+
To = message.To.Mailboxes.First().Address,
69+
Body = message.HtmlBody,
70+
},
71+
cancellationToken);
72+
73+
return SmtpResponse.Ok;
74+
}
75+
76+
public Message? WaitForMessage(Func<Message, bool> condition, CancellationToken cancellationToken = default)
77+
{
78+
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
79+
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
80+
81+
foreach (var message in messages.GetConsumingEnumerable(linked.Token))
82+
if (condition(message))
83+
return message;
84+
85+
return null;
86+
}
87+
88+
public partial class Message
89+
{
90+
public required string From { get; set; }
91+
92+
public required string To { get; set; }
93+
94+
public required string Body { get; set; }
95+
96+
[GeneratedRegex("token=(.*)\"")]
97+
private static partial Regex TokenRegex();
98+
99+
public string GetToken()
100+
=> Uri.UnescapeDataString(TokenRegex().Match(Body).Groups[1].Value);
101+
}
102+
}

Pyro.Api/Pyro.ApiTests/Pyro.ApiTests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1818
</PackageReference>
1919
<PackageReference Include="Microsoft.NET.Test.Sdk" />
20+
<PackageReference Include="MimeKit" />
2021
<PackageReference Include="NSubstitute" />
2122
<PackageReference Include="NSubstitute.Analyzers.CSharp">
2223
<PrivateAssets>all</PrivateAssets>
@@ -28,6 +29,7 @@
2829
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2930
</PackageReference>
3031
<PackageReference Include="NUnit3TestAdapter" />
32+
<PackageReference Include="SmtpServer" />
3133
<PackageReference Include="Testcontainers" />
3234
</ItemGroup>
3335

Pyro.Api/Pyro.ApiTests/Tests/LockUserTests.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,23 @@ public async Task SetUp()
2424
identityClient = pyroClient.Share<IdentityClient>();
2525
await pyroClient.Login();
2626

27-
login = faker.Random.Hash(32);
28-
password = faker.Random.Hash();
27+
login = faker.Internet.Email();
2928

30-
var createUserRequest = new CreateUserRequest(login, password, ["User"]);
29+
var createUserRequest = new CreateUserRequest(login, ["User"]);
3130
var user = await identityClient.CreateUser(createUserRequest);
3231
Assert.That(user, Is.Not.Null);
32+
Assert.That(user.IsLocked, Is.True);
33+
34+
var message = Api.Smtp.WaitForMessage(x => x.To == login) ??
35+
throw new InvalidOperationException("The message was not found.");
36+
var token = message.GetToken();
37+
password = faker.Random.Hash();
38+
var activateUserRequest = new ActivateUserRequest(token, password);
39+
await identityClient.ActivateUser(activateUserRequest);
40+
41+
user = await identityClient.GetUser(login);
42+
Assert.That(user, Is.Not.Null);
43+
Assert.That(user.IsLocked, Is.False);
3344
}
3445

3546
[OneTimeTearDown]

Pyro.Api/Pyro.ApiTests/Tests/ProfileTests.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
22
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.
33

4+
using Bogus;
45
using Pyro.ApiTests.Clients;
56
using Pyro.Contracts.Requests;
67
using Pyro.Contracts.Requests.Identity;
@@ -9,12 +10,14 @@ namespace Pyro.ApiTests.Tests;
910

1011
public class ProfileTests
1112
{
13+
private Faker faker;
1214
private PyroClient client;
1315
private IdentityClient identityClient;
1416

1517
[OneTimeSetUp]
1618
public async Task SetUp()
1719
{
20+
faker = new Faker();
1821
client = new PyroClient(Api.BaseAddress);
1922
identityClient = client.Share<IdentityClient>();
2023
await client.Login();
@@ -49,14 +52,22 @@ public async Task UpdateGetProfile()
4952
[Test]
5053
public async Task GetProfileOfNewlyCreatedUser()
5154
{
55+
var login = faker.Internet.Email();
5256
var request = new CreateUserRequest(
53-
Guid.NewGuid().ToString().Replace("-", string.Empty),
54-
Guid.NewGuid().ToString(),
57+
login,
5558
["Admin"]);
5659
await identityClient.CreateUser(request);
5760

61+
var message = Api.Smtp.WaitForMessage(x => x.To == login) ??
62+
throw new InvalidOperationException("The message was not found.");
63+
var token = message.GetToken();
64+
var password = faker.Random.Hash();
65+
var activateUserRequest = new ActivateUserRequest(token, password);
66+
await identityClient.ActivateUser(activateUserRequest);
67+
5868
using var newUserClient = new PyroClient(Api.BaseAddress);
59-
await newUserClient.Login(request.Login, request.Password);
69+
70+
await newUserClient.Login(request.Login, password);
6071

6172
var profile = await newUserClient.GetProfile();
6273

0 commit comments

Comments
 (0)