Skip to content

Commit

Permalink
Merge branch 'develop' into chore/try-to-fix-playwright-reset-tests
Browse files Browse the repository at this point in the history
Pull in changes to deleting draft projects, necessary in order to run
Playwright E2E tests more than once
  • Loading branch information
rmunn committed May 28, 2024
2 parents 9c25907 + b83c78b commit 6f86d26
Show file tree
Hide file tree
Showing 75 changed files with 2,285 additions and 2,003 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ HASURA_GRAPHQL_ADMIN_SECRET=c67eeb626bee405d883b482046934860
# Get test key at https://ui.honeycomb.io/sil-language-forge/environments/test/api_keys
HONEYCOMB_API_KEY=__REPLACE__
OTEL_RESOURCE_ATTRIBUTES=service.version=0.0.1,deployment.environment=dev
OTEL_DOTNET_EXPERIMENTAL_ASPNETCORE_DISABLE_URL_QUERY_REDACTION=true

SMTP_USER=maildev
SMTP_PASSWORD=maildev
7 changes: 4 additions & 3 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,12 @@ jobs:
curl -s --head "$TARGET" > response.txt
# get version from response, trim off the header and fix the line endings
versionHeader=$((grep "lexbox-version" response.txt || echo VersionNotFound) | cut -d' ' -f 2 | tr -d '[:space:]')
if [[ "$versionHeader" == "$EXPECTED_VERSION" ]]; then
echo "Version is correct"
status_code=$(grep -oP "HTTP\/\d(\.\d)? \K\d+" response.txt)
if [[ "$versionHeader" == "$EXPECTED_VERSION" && "$status_code" == "200" ]]; then
echo "Version and status code are correct"
exit 0
else
echo "Version '$versionHeader' is incorrect, expected '$EXPECTED_VERSION'"
echo "Health check failed, Version '$versionHeader', expected '$EXPECTED_VERSION', status code '$status_code'"
n=$((n+1))
sleep $((DelayMultiplier * n))
fi
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ local.env
cookies.txt
dump.sql
test-results/
**/*.sqlite
**/*.sqlite-*
1 change: 1 addition & 0 deletions .idea/.idea.LexBox/.idea/indexLayout.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ tasks:
- echo "#OTEL_SDK_DISABLED=true" >> deployment/local-dev/local.env
- echo "GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com" >> deployment/local-dev/local.env
- echo "GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__" >> deployment/local-dev/local.env
- kubectl --context=docker-desktop apply -f deployment/setup/namespace.yaml
setup-win:
platforms: [ windows ]
cmds:
Expand Down
82 changes: 64 additions & 18 deletions backend/LexBoxApi/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.Models;
using LexBoxApi.Otel;
using LexBoxApi.Services;
Expand Down Expand Up @@ -65,27 +66,51 @@ public async Task<ActionResult<LexAuthUser>> RegisterAccount(RegisterAccountInpu
return ValidationProblem(ModelState);
}

var jwtUser = _loggedInContext.MaybeUser;
var emailVerified = jwtUser?.Email == accountInput.Email;
var userEntity = CreateUserEntity(accountInput, emailVerified: false);
registerActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
await _lexBoxDbContext.SaveChangesAsync();

var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
var user = new LexAuthUser(userEntity);
await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
new AuthenticationProperties { IsPersistent = true });

await _emailService.SendVerifyAddressEmail(userEntity);
return Ok(user);
}

[HttpPost("acceptInvitation")]
[RequireAudience(LexboxAudience.RegisterAccount, true)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesErrorResponseType(typeof(Dictionary<string, string[]>))]
[ProducesDefaultResponseType]
public async Task<ActionResult<LexAuthUser>> AcceptEmailInvitation(RegisterAccountInput accountInput)
{
using var acceptActivity = LexBoxActivitySource.Get().StartActivity("AcceptInvitation");
var validToken = await _turnstileService.IsTokenValid(accountInput.TurnstileToken, accountInput.Email);
acceptActivity?.AddTag("app.turnstile_token_valid", validToken);
if (!validToken)
{
Id = Guid.NewGuid(),
Name = accountInput.Name,
Email = accountInput.Email,
LocalizationCode = accountInput.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(accountInput.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(accountInput.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
Locked = false,
CanCreateProjects = false
};
registerActivity?.AddTag("app.user.id", userEntity.Id);
ModelState.AddModelError<RegisterAccountInput>(r => r.TurnstileToken, "token invalid");
return ValidationProblem(ModelState);
}

var jwtUser = _loggedInContext.User;

var hasExistingUser = await _lexBoxDbContext.Users.FilterByEmailOrUsername(accountInput.Email).AnyAsync();
acceptActivity?.AddTag("app.email_available", !hasExistingUser);
if (hasExistingUser)
{
ModelState.AddModelError<RegisterAccountInput>(r => r.Email, "email already in use");
return ValidationProblem(ModelState);
}

var emailVerified = jwtUser.Email == accountInput.Email;
var userEntity = CreateUserEntity(accountInput, emailVerified);
acceptActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
if (jwtUser is not null && jwtUser.Projects.Length > 0)
// This audience check is redundant now because of [RequireAudience(LexboxAudience.RegisterAccount, true)], but let's leave it in for safety
if (jwtUser.Audience == LexboxAudience.RegisterAccount && jwtUser.Projects.Length > 0)
{
userEntity.Projects = jwtUser.Projects.Select(p => new ProjectUsers { Role = p.Role, ProjectId = p.ProjectId }).ToList();
}
Expand All @@ -99,6 +124,27 @@ await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
return Ok(user);
}

private User CreateUserEntity(RegisterAccountInput input, bool emailVerified, Guid? creatorId = null)
{
var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
{
Id = Guid.NewGuid(),
Name = input.Name,
Email = input.Email,
LocalizationCode = input.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
CreatedById = creatorId,
Locked = false,
CanCreateProjects = false
};
return userEntity;
}

[HttpPost("sendVerificationEmail")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using LexCore.ServiceInterfaces;
using LfClassicData;
using MongoDB.Driver;
using MongoDB.Driver.Linq;

namespace LexBoxApi.GraphQL.CustomTypes;

public class IsLanguageForgeProjectDataLoader : BatchDataLoader<string, bool>, IIsLanguageForgeProjectDataLoader
{
private readonly SystemDbContext _systemDbContext;

public IsLanguageForgeProjectDataLoader(
SystemDbContext systemDbContext,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options)
{
_systemDbContext = systemDbContext;
}

protected override async Task<IReadOnlyDictionary<string, bool>> LoadBatchAsync(
IReadOnlyList<string> projectCodes,
CancellationToken cancellationToken)
{
return await MongoExtensions.ToAsyncEnumerable(_systemDbContext.Projects.AsQueryable()
.Select(p => p.ProjectCode)
.Where(projectCode => projectCodes.Contains(projectCode)))
.ToDictionaryAsync(projectCode => projectCode, _ => true, cancellationToken);
}
}
1 change: 1 addition & 0 deletions backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
.InitializeOnStartup()
.RegisterDbContext<LexBoxDbContext>()
.RegisterService<IHgService>()
.RegisterService<IIsLanguageForgeProjectDataLoader>()
.RegisterService<LoggedInContext>()
.RegisterService<EmailService>()
.RegisterService<LexAuthService>()
Expand Down
53 changes: 36 additions & 17 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
BulkAddProjectMembersInput input,
LexBoxDbContext dbContext)
{
var project = await dbContext.Projects.FindAsync(input.ProjectId);
if (project is null) throw new NotFoundException("Project not found", "project");
if (input.ProjectId.HasValue)
{
var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId.Value);
if (!projectExists) throw new NotFoundException("Project not found", "project");
}
List<UserProjectRole> AddedMembers = [];
List<UserProjectRole> CreatedMembers = [];
List<UserProjectRole> ExistingMembers = [];
Expand Down Expand Up @@ -154,10 +157,13 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
CanCreateProjects = false
};
CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role));
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
if (input.ProjectId.HasValue)
{
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
dbContext.Add(user);
}
else
else if (input.ProjectId.HasValue)
{
var userProject = user.Projects.FirstOrDefault(p => p.ProjectId == input.ProjectId);
if (userProject is not null)
Expand All @@ -168,9 +174,14 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
{
AddedMembers.Add(new UserProjectRole(user.Username ?? user.Email!, input.Role));
// Not yet a member, so add a membership. We don't want to touch existing memberships, which might have other roles
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
}
else
{
// No project ID specified, user already exists. This is probably part of bulk-adding through the admin dashboard or org page.
ExistingMembers.Add(new UserProjectRole(user.Username ?? user.Email!, ProjectRole.Unknown));
}
}
await dbContext.SaveChangesAsync();
return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers);
Expand Down Expand Up @@ -339,6 +350,25 @@ await dbContext.ProjectUsers.Where(pu => pu.ProjectId == input.ProjectId && pu.U
return dbContext.Projects.Where(p => p.Id == input.ProjectId);
}

[Error<NotFoundException>]
[Error<DbError>]
[AdminRequired]
[UseMutationConvention]
[UseProjection]
public async Task<DraftProject> DeleteDraftProject(
Guid draftProjectId,
LexBoxDbContext dbContext)
{
var deletedDraft = await dbContext.DraftProjects.FindAsync(draftProjectId);
if (deletedDraft == null)
{
throw NotFoundException.ForType<DraftProject>();
}
// Draft projects are deleted immediately, not soft-deleted
dbContext.DraftProjects.Remove(deletedDraft);
await dbContext.SaveChangesAsync();
return deletedDraft;
}

[Error<NotFoundException>]
[Error<DbError>]
Expand All @@ -356,18 +386,7 @@ public async Task<IQueryable<Project>> SoftDeleteProject(
var project = await dbContext.Projects.Include(p => p.Users).FirstOrDefaultAsync(p => p.Id == projectId);
if (project is null)
{
// Draft projects, if any, are deleted immediately, not soft-deleted
var deletedDraftCount = await dbContext.DraftProjects.Where(dp => dp.Id == projectId).ExecuteDeleteAsync();
if (deletedDraftCount == 0)
{
// No draft project either, so return standard project not found error
throw NotFoundException.ForType<Project>();
}
else
{
// Return an empty project list to indicate success
return dbContext.Projects.Where(p => p.Id == projectId);
}
throw NotFoundException.ForType<Project>();
}
if (project.DeletedDate is not null) throw new InvalidOperationException("Project already deleted");

Expand Down
60 changes: 60 additions & 0 deletions backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.Models.Project;
using LexBoxApi.Otel;
using LexBoxApi.Services;
using LexCore;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
using LexData;
using LexData.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -23,6 +27,13 @@ public record ChangeUserAccountBySelfInput(Guid UserId, string? Email, string Na
: ChangeUserAccountDataInput(UserId, Email, Name);
public record ChangeUserAccountByAdminInput(Guid UserId, string? Email, string Name, UserRole Role)
: ChangeUserAccountDataInput(UserId, Email, Name);
public record CreateGuestUserByAdminInput(
string? Email,
string Name,
string? Username,
string Locale,
string PasswordHash,
int PasswordStrength);

[Error<NotFoundException>]
[Error<DbError>]
Expand Down Expand Up @@ -63,6 +74,55 @@ EmailService emailService
return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService);
}

[Error<NotFoundException>]
[Error<DbError>]
[Error<UniqueValueException>]
[Error<RequiredException>]
[AdminRequired]
public async Task<LexAuthUser> CreateGuestUserByAdmin(
LoggedInContext loggedInContext,
CreateGuestUserByAdminInput input,
LexBoxDbContext dbContext,
EmailService emailService
)
{
using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser");

var hasExistingUser = input.Email is null && input.Username is null
? throw new RequiredException("Guest users must have either an email or a username")
: await dbContext.Users.FilterByEmailOrUsername(input.Email ?? input.Username!).AnyAsync();
createGuestUserActivity?.AddTag("app.email_available", !hasExistingUser);
if (hasExistingUser) throw new UniqueValueException("Email");

var admin = loggedInContext.User;

var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
{
Id = Guid.NewGuid(),
Name = input.Name,
Email = input.Email,
Username = input.Username,
LocalizationCode = input.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength),
IsAdmin = false,
EmailVerified = false,
CreatedById = admin.Id,
Locked = false,
CanCreateProjects = false
};
createGuestUserActivity?.AddTag("app.user.id", userEntity.Id);
dbContext.Users.Add(userEntity);
await dbContext.SaveChangesAsync();
if (!string.IsNullOrEmpty(input.Email))
{
await emailService.SendVerifyAddressEmail(userEntity);
}
return new LexAuthUser(userEntity);
}

private static async Task<User> UpdateUser(
LoggedInContext loggedInContext,
IPermissionService permissionService,
Expand Down
Loading

0 comments on commit 6f86d26

Please sign in to comment.