diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props index 5373de494f5..92bc5c44d6e 100644 --- a/backend/packagegroups/NuGet.props +++ b/backend/packagegroups/NuGet.props @@ -50,14 +50,15 @@ - + - - + + + diff --git a/backend/src/Designer/Controllers/AppScopesController.cs b/backend/src/Designer/Controllers/AppScopesController.cs new file mode 100644 index 00000000000..f3c699e9244 --- /dev/null +++ b/backend/src/Designer/Controllers/AppScopesController.cs @@ -0,0 +1,77 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Constants; +using Altinn.Studio.Designer.Helpers; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Models.Dto; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Altinn.Studio.Designer.Services.Interfaces; +using Altinn.Studio.Designer.TypedHttpClients.MaskinPorten; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.FeatureManagement.Mvc; + + +namespace Altinn.Studio.Designer.Controllers; + +[FeatureGate(StudioFeatureFlags.AnsattPorten)] +[Route("designer/api/{org}/{app:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/app-scopes")] + +public class AppScopesController(IMaskinPortenHttpClient maskinPortenHttpClient, + IAppScopesService appScopesService) : ControllerBase +{ + [Authorize(AnsattPortenConstants.AnsattportenAuthorizationPolicy)] + [HttpGet("maskinporten")] + public async Task GetScopesFromMaskinPorten(string org, string app, CancellationToken cancellationToken) + { + var scopes = await maskinPortenHttpClient.GetAvailableScopes(cancellationToken); + + var reponse = new AppScopesResponse() + { + Scopes = scopes.Select(x => new MaskinPortenScopeDto() + { + Scope = x.Scope, + Description = x.Description + }).ToHashSet() + }; + + return Ok(reponse); + } + + + [Authorize] + [HttpPut] + public async Task UpsertAppScopes(string org, string app, [FromBody] AppScopesUpsertRequest appScopesUpsertRequest, + CancellationToken cancellationToken) + { + var scopes = appScopesUpsertRequest.Scopes.Select(x => new MaskinPortenScopeEntity() + { + Scope = x.Scope, + Description = x.Description + }).ToHashSet(); + + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + await appScopesService.UpsertScopesAsync(AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer), scopes, cancellationToken); + } + + + [Authorize] + [HttpGet] + public async Task GetAppScopes(string org, string app, CancellationToken cancellationToken) + { + var appScopes = await appScopesService.GetAppScopesAsync(AltinnRepoContext.FromOrgRepo(org, app), cancellationToken); + + var reponse = new AppScopesResponse() + { + Scopes = appScopes?.Scopes.Select(x => new MaskinPortenScopeDto() + { + Scope = x.Scope, + Description = x.Description + }).ToHashSet() ?? [] + }; + + return Ok(reponse); + } + +} diff --git a/backend/src/Designer/Controllers/MaskinPortenController.cs b/backend/src/Designer/Controllers/MaskinPortenController.cs deleted file mode 100644 index 2c0e1ebae94..00000000000 --- a/backend/src/Designer/Controllers/MaskinPortenController.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Altinn.Studio.Designer.Constants; -using Altinn.Studio.Designer.TypedHttpClients.MaskinPorten; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.FeatureManagement.Mvc; - - -namespace Altinn.Studio.Designer.Controllers; - -[FeatureGate(StudioFeatureFlags.AnsattPorten)] -[Route("designer/api/[controller]")] -public class MaskinPortenController(IMaskinPortenHttpClient maskinPortenHttpClient) : ControllerBase -{ - - // TODO: Cleanup model and create separation between presentation dto, domain model and external api model - // Will be done under: https://github.com/Altinn/altinn-studio/issues/12767 and https://github.com/Altinn/altinn-studio/issues/12766 - [Authorize(AnsattPortenConstants.AnsattportenAuthorizationPolicy)] - [HttpGet("scopes")] - public async Task Get(CancellationToken cancellationToken) - { - var scopes = await maskinPortenHttpClient.GetAvailableScopes(cancellationToken); - return Ok(scopes); - } -} diff --git a/backend/src/Designer/Infrastructure/AnsattPorten/AnsattPortenExtensions.cs b/backend/src/Designer/Infrastructure/AnsattPorten/AnsattPortenExtensions.cs index 1f76ad1f87b..8cfdfcc3881 100644 --- a/backend/src/Designer/Infrastructure/AnsattPorten/AnsattPortenExtensions.cs +++ b/backend/src/Designer/Infrastructure/AnsattPorten/AnsattPortenExtensions.cs @@ -75,7 +75,8 @@ private static IServiceCollection AddAnsattPortenAuthentication(this IServiceCol options.Events.OnRedirectToIdentityProvider = context => { - if (!context.Request.Path.StartsWithSegments("/designer/api/maskinporten")) + if (!context.Request.Path.StartsWithSegments("/designer/api") || + !context.Request.Path.Value!.Contains("/maskinporten")) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.HandleResponse(); diff --git a/backend/src/Designer/Infrastructure/ServiceRegistration.cs b/backend/src/Designer/Infrastructure/ServiceRegistration.cs index 4218ad0431f..e85ecd0f2bf 100644 --- a/backend/src/Designer/Infrastructure/ServiceRegistration.cs +++ b/backend/src/Designer/Infrastructure/ServiceRegistration.cs @@ -56,8 +56,10 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/backend/src/Designer/Migrations/20240919171846_AppScopesTable.Designer.cs b/backend/src/Designer/Migrations/20240919171846_AppScopesTable.Designer.cs new file mode 100644 index 00000000000..518bfcaf341 --- /dev/null +++ b/backend/src/Designer/Migrations/20240919171846_AppScopesTable.Designer.cs @@ -0,0 +1,169 @@ +// +using System; +using Altinn.Studio.Designer.Repository.ORMImplementation.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Altinn.Studio.Designer.Migrations +{ + [DbContext(typeof(DesignerdbContext))] + [Migration("20240919171846_AppScopesTable")] + partial class AppScopesTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseSerialColumns(modelBuilder); + + modelBuilder.Entity("Altinn.Studio.Designer.Repository.ORMImplementation.Models.AppScopesDbObject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("App") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("app"); + + b.Property("Created") + .HasColumnType("timestamptz") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("character varying") + .HasColumnName("created_by"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying") + .HasColumnName("last_modified_by"); + + b.Property("Org") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("org"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("app_scopes_pkey"); + + b.HasIndex(new[] { "Org", "App" }, "idx_app_scopes_org_app") + .IsUnique(); + + b.ToTable("app_scopes", "designer"); + }); + + modelBuilder.Entity("Altinn.Studio.Designer.Repository.ORMImplementation.Models.Deployment", b => + { + b.Property("Sequenceno") + .ValueGeneratedOnAdd() + .HasColumnType("BIGSERIAL") + .HasColumnName("sequenceno"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Sequenceno")); + + b.Property("App") + .HasColumnType("character varying") + .HasColumnName("app"); + + b.Property("Buildid") + .HasColumnType("character varying") + .HasColumnName("buildid"); + + b.Property("Buildresult") + .HasColumnType("character varying") + .HasColumnName("buildresult"); + + b.Property("Created") + .HasColumnType("timestamptz") + .HasColumnName("created"); + + b.Property("Entity") + .HasColumnType("text") + .HasColumnName("entity"); + + b.Property("Org") + .HasColumnType("character varying") + .HasColumnName("org"); + + b.Property("Tagname") + .HasColumnType("character varying") + .HasColumnName("tagname"); + + b.HasKey("Sequenceno") + .HasName("deployments_pkey"); + + b.HasIndex(new[] { "Org", "App" }, "idx_deployments_org_app"); + + b.ToTable("deployments", "designer"); + }); + + modelBuilder.Entity("Altinn.Studio.Designer.Repository.ORMImplementation.Models.Release", b => + { + b.Property("Sequenceno") + .ValueGeneratedOnAdd() + .HasColumnType("BIGSERIAL") + .HasColumnName("sequenceno"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Sequenceno")); + + b.Property("App") + .HasColumnType("character varying") + .HasColumnName("app"); + + b.Property("Buildid") + .HasColumnType("character varying") + .HasColumnName("buildid"); + + b.Property("Buildresult") + .HasColumnType("character varying") + .HasColumnName("buildresult"); + + b.Property("Buildstatus") + .HasColumnType("character varying") + .HasColumnName("buildstatus"); + + b.Property("Created") + .HasColumnType("timestamptz") + .HasColumnName("created"); + + b.Property("Entity") + .HasColumnType("text") + .HasColumnName("entity"); + + b.Property("Org") + .HasColumnType("character varying") + .HasColumnName("org"); + + b.Property("Tagname") + .HasColumnType("character varying") + .HasColumnName("tagname"); + + b.HasKey("Sequenceno") + .HasName("releases_pkey"); + + b.HasIndex(new[] { "Org", "App" }, "idx_releases_org_app"); + + b.ToTable("releases", "designer"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Designer/Migrations/20240919171846_AppScopesTable.cs b/backend/src/Designer/Migrations/20240919171846_AppScopesTable.cs new file mode 100644 index 00000000000..056b570c843 --- /dev/null +++ b/backend/src/Designer/Migrations/20240919171846_AppScopesTable.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Altinn.Studio.Designer.Migrations +{ + /// + public partial class AppScopesTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "app_scopes", + schema: "designer", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + app = table.Column(type: "character varying", nullable: false), + org = table.Column(type: "character varying", nullable: false), + created = table.Column(type: "timestamptz", nullable: false), + scopes = table.Column(type: "jsonb", nullable: false), + created_by = table.Column(type: "character varying", nullable: true), + last_modified_by = table.Column(type: "character varying", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("app_scopes_pkey", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "idx_app_scopes_org_app", + schema: "designer", + table: "app_scopes", + columns: new[] { "org", "app" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "app_scopes", + schema: "designer"); + } + } +} diff --git a/backend/src/Designer/Migrations/DesignerdbContextModelSnapshot.cs b/backend/src/Designer/Migrations/DesignerdbContextModelSnapshot.cs index c2ef1351c0e..60df1681b56 100644 --- a/backend/src/Designer/Migrations/DesignerdbContextModelSnapshot.cs +++ b/backend/src/Designer/Migrations/DesignerdbContextModelSnapshot.cs @@ -17,11 +17,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("ProductVersion", "8.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseSerialColumns(modelBuilder); + modelBuilder.Entity("Altinn.Studio.Designer.Repository.ORMImplementation.Models.AppScopesDbObject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("App") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("app"); + + b.Property("Created") + .HasColumnType("timestamptz") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("character varying") + .HasColumnName("created_by"); + + b.Property("LastModifiedBy") + .HasColumnType("character varying") + .HasColumnName("last_modified_by"); + + b.Property("Org") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("org"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("app_scopes_pkey"); + + b.HasIndex(new[] { "Org", "App" }, "idx_app_scopes_org_app") + .IsUnique(); + + b.ToTable("app_scopes", "designer"); + }); + modelBuilder.Entity("Altinn.Studio.Designer.Repository.ORMImplementation.Models.Deployment", b => { b.Property("Sequenceno") diff --git a/backend/src/Designer/Models/Dto/AppScopesRequest.cs b/backend/src/Designer/Models/Dto/AppScopesRequest.cs new file mode 100644 index 00000000000..406ad8247f1 --- /dev/null +++ b/backend/src/Designer/Models/Dto/AppScopesRequest.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Altinn.Studio.Designer.Models.Dto; + +public record AppScopesUpsertRequest +{ + public ISet Scopes { get; set; } +} diff --git a/backend/src/Designer/Models/Dto/AppScopesResponse.cs b/backend/src/Designer/Models/Dto/AppScopesResponse.cs new file mode 100644 index 00000000000..4d1c0e80af3 --- /dev/null +++ b/backend/src/Designer/Models/Dto/AppScopesResponse.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Altinn.Studio.Designer.Models.Dto; + +public record AppScopesResponse +{ + public ISet Scopes { get; set; } +} diff --git a/backend/src/Designer/Models/Dto/MaskinPortenScopeDto.cs b/backend/src/Designer/Models/Dto/MaskinPortenScopeDto.cs new file mode 100644 index 00000000000..26df67d02a5 --- /dev/null +++ b/backend/src/Designer/Models/Dto/MaskinPortenScopeDto.cs @@ -0,0 +1,7 @@ +namespace Altinn.Studio.Designer.Models.Dto; + +public record MaskinPortenScopeDto +{ + public string Scope { get; set; } + public string Description { get; set; } +} diff --git a/backend/src/Designer/Program.cs b/backend/src/Designer/Program.cs index 956a9416685..bd8c6a9dbb8 100644 --- a/backend/src/Designer/Program.cs +++ b/backend/src/Designer/Program.cs @@ -212,8 +212,8 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration services.ConfigureNonMarkedSettings(configuration); services.RegisterTypedHttpClients(configuration); - services.ConfigureAuthentication(configuration, env); services.AddAnsattPortenAuthenticationAndAuthorization(configuration); + services.ConfigureAuthentication(configuration, env); services.Configure(configuration.GetSection("CacheSettings")); diff --git a/backend/src/Designer/Repository/IAppScopesRepository.cs b/backend/src/Designer/Repository/IAppScopesRepository.cs new file mode 100644 index 00000000000..94b78f6581c --- /dev/null +++ b/backend/src/Designer/Repository/IAppScopesRepository.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Repository.Models.AppScope; + +namespace Altinn.Studio.Designer.Repository; + +public interface IAppScopesRepository +{ + Task GetAppScopesAsync(AltinnRepoContext repoContext, CancellationToken cancellationToken = default); + Task UpsertAppScopesAsync(AppScopesEntity appScopesEntity, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Designer/Repository/Models/AppScope/AppScopesEntity.cs b/backend/src/Designer/Repository/Models/AppScope/AppScopesEntity.cs new file mode 100644 index 00000000000..e2dcc80557b --- /dev/null +++ b/backend/src/Designer/Repository/Models/AppScope/AppScopesEntity.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace Altinn.Studio.Designer.Repository.Models.AppScope; + +public class AppScopesEntity +{ + public ISet Scopes { get; set; } + public DateTimeOffset Created { get; set; } + public string CreatedBy { get; set; } + public string App { get; set; } + public string Org { get; set; } + public string LastModifiedBy { get; set; } + public uint Version { get; set; } +} + diff --git a/backend/src/Designer/Repository/Models/AppScope/MaskinPortenScopeEntity.cs b/backend/src/Designer/Repository/Models/AppScope/MaskinPortenScopeEntity.cs new file mode 100644 index 00000000000..879483796b8 --- /dev/null +++ b/backend/src/Designer/Repository/Models/AppScope/MaskinPortenScopeEntity.cs @@ -0,0 +1,7 @@ +namespace Altinn.Studio.Designer.Repository.Models.AppScope; + +public record MaskinPortenScopeEntity +{ + public string Scope { get; set; } + public string Description { get; set; } +} diff --git a/backend/src/Designer/Repository/ORMImplementation/AppScopesRepository.cs b/backend/src/Designer/Repository/ORMImplementation/AppScopesRepository.cs new file mode 100644 index 00000000000..3320834b210 --- /dev/null +++ b/backend/src/Designer/Repository/ORMImplementation/AppScopesRepository.cs @@ -0,0 +1,50 @@ +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Altinn.Studio.Designer.Repository.ORMImplementation.Data; +using Altinn.Studio.Designer.Repository.ORMImplementation.Mappers; +using Altinn.Studio.Designer.Repository.ORMImplementation.Models; +using Microsoft.EntityFrameworkCore; + +namespace Altinn.Studio.Designer.Repository.ORMImplementation; + +public class AppScopesRepository : IAppScopesRepository +{ + private readonly DesignerdbContext _dbContext; + + public AppScopesRepository(DesignerdbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task GetAppScopesAsync(AltinnRepoContext repoContext, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var appScope = await _dbContext.AppScopes.AsNoTracking().SingleOrDefaultAsync(a => a.Org == repoContext.Org && a.App == repoContext.Repo, cancellationToken); + + return appScope is null ? null : AppScopesMapper.MapToModel(appScope); + } + + public async Task UpsertAppScopesAsync(AppScopesEntity appScopesEntity, + CancellationToken cancellationToken = default) + { + AppScopesDbObject existing = await _dbContext.AppScopes.AsNoTracking().SingleOrDefaultAsync(a => a.Org == appScopesEntity.Org && a.App == appScopesEntity.App, cancellationToken); + + var dbObject = existing is null + ? AppScopesMapper.MapToDbModel(appScopesEntity) + : AppScopesMapper.MapToDbModel(appScopesEntity, existing.Id); + + if (existing is null) + { + _dbContext.AppScopes.Add(dbObject); + } + else + { + _dbContext.Entry(dbObject).State = EntityState.Modified; + } + + await _dbContext.SaveChangesAsync(cancellationToken); + return AppScopesMapper.MapToModel(dbObject); + } +} diff --git a/backend/src/Designer/Repository/ORMImplementation/Data/DesignerdbContext.cs b/backend/src/Designer/Repository/ORMImplementation/Data/DesignerdbContext.cs index 82380acdde9..7131d1b85fb 100644 --- a/backend/src/Designer/Repository/ORMImplementation/Data/DesignerdbContext.cs +++ b/backend/src/Designer/Repository/ORMImplementation/Data/DesignerdbContext.cs @@ -12,15 +12,15 @@ public DesignerdbContext(DbContextOptions options) } public virtual DbSet Deployments { get; set; } - public virtual DbSet Releases { get; set; } + public virtual DbSet AppScopes { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.UseSerialColumns(); modelBuilder.ApplyConfiguration(new DeploymentConfiguration()); modelBuilder.ApplyConfiguration(new ReleaseConfiguration()); + modelBuilder.ApplyConfiguration(new AppScopesConfiguration()); base.OnModelCreating(modelBuilder); } - - } diff --git a/backend/src/Designer/Repository/ORMImplementation/Data/EntityConfigurations/AppScopesConfiguration.cs b/backend/src/Designer/Repository/ORMImplementation/Data/EntityConfigurations/AppScopesConfiguration.cs new file mode 100644 index 00000000000..a07757d8589 --- /dev/null +++ b/backend/src/Designer/Repository/ORMImplementation/Data/EntityConfigurations/AppScopesConfiguration.cs @@ -0,0 +1,54 @@ +using Altinn.Studio.Designer.Repository.ORMImplementation.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Altinn.Studio.Designer.Repository.ORMImplementation.Data.EntityConfigurations; + +public class AppScopesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("app_scopes", "designer"); + + builder.HasKey(e => e.Id).HasName("app_scopes_pkey"); + + builder.Property(e => e.Id) + .HasColumnName("id") + .IsRequired() + .ValueGeneratedOnAdd() + .UseIdentityColumn(); + + builder.Property(e => e.App) + .HasColumnType("character varying") + .HasColumnName("app") + .IsRequired(); + + builder.Property(e => e.Org) + .HasColumnType("character varying") + .HasColumnName("org") + .IsRequired(); + + builder.Property(e => e.Created) + .HasColumnType("timestamptz") + .HasColumnName("created"); + + builder.Property(e => e.Scopes) + .HasColumnType("jsonb") + .HasColumnName("scopes") + .IsRequired(); + + builder.Property(e => e.CreatedBy) + .HasColumnType("character varying") + .HasColumnName("created_by"); + + builder.Property(e => e.LastModifiedBy) + .HasColumnType("character varying") + .HasColumnName("last_modified_by"); + + builder.Property(e => e.Version) + .IsRowVersion(); + + builder.HasIndex(e => new { e.Org, e.App }, "idx_app_scopes_org_app") + .IsUnique(); + } +} diff --git a/backend/src/Designer/Repository/ORMImplementation/Mappers/AppScopesMapper.cs b/backend/src/Designer/Repository/ORMImplementation/Mappers/AppScopesMapper.cs new file mode 100644 index 00000000000..ca348b6c87d --- /dev/null +++ b/backend/src/Designer/Repository/ORMImplementation/Mappers/AppScopesMapper.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Text.Json; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Altinn.Studio.Designer.Repository.ORMImplementation.Models; + +namespace Altinn.Studio.Designer.Repository.ORMImplementation.Mappers; + +public class AppScopesMapper +{ + private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public static AppScopesDbObject MapToDbModel(AppScopesEntity appScopes) + { + return new AppScopesDbObject + { + App = appScopes.App, + Org = appScopes.Org, + Created = appScopes.Created, + Scopes = JsonSerializer.Serialize(appScopes.Scopes, s_jsonOptions), + CreatedBy = appScopes.CreatedBy, + LastModifiedBy = appScopes.LastModifiedBy, + Version = appScopes.Version + }; + } + + public static AppScopesDbObject MapToDbModel(AppScopesEntity appScopes, long id) + { + var dbModel = MapToDbModel(appScopes); + dbModel.Id = id; + return dbModel; + } + + public static AppScopesEntity MapToModel(AppScopesDbObject appScopesDbObject) + { + return new AppScopesEntity + { + App = appScopesDbObject.App, + Org = appScopesDbObject.Org, + Created = appScopesDbObject.Created, + Scopes = JsonSerializer.Deserialize>(appScopesDbObject.Scopes, s_jsonOptions), + CreatedBy = appScopesDbObject.CreatedBy, + LastModifiedBy = appScopesDbObject.LastModifiedBy, + Version = appScopesDbObject.Version + }; + } +} diff --git a/backend/src/Designer/Repository/ORMImplementation/Models/AppScopesDbObject.cs b/backend/src/Designer/Repository/ORMImplementation/Models/AppScopesDbObject.cs new file mode 100644 index 00000000000..e6de599b825 --- /dev/null +++ b/backend/src/Designer/Repository/ORMImplementation/Models/AppScopesDbObject.cs @@ -0,0 +1,46 @@ +using System; + +namespace Altinn.Studio.Designer.Repository.ORMImplementation.Models; + +public class AppScopesDbObject +{ + /// + /// The unique identifier for the object + /// + public long Id { get; set; } + + /// + /// The app name + /// + public string App { get; set; } + + /// + /// The organization name + /// + public string Org { get; set; } + + /// + /// The time the object was created + /// + public DateTimeOffset Created { get; set; } + + /// + /// Maskinporten scopes saved as JSON array + /// + public string Scopes { get; set; } + + /// + /// Identifies the user who created the object + /// + public string CreatedBy { get; set; } + + /// + /// Identifies the user who last modified the object + /// + public string LastModifiedBy { get; set; } + + /// + /// This will be used as concurrency token to handle optimistic concurrency + /// + public uint Version { get; set; } +} diff --git a/backend/src/Designer/Services/Implementation/AppScopesService.cs b/backend/src/Designer/Services/Implementation/AppScopesService.cs new file mode 100644 index 00000000000..8e06cac12f1 --- /dev/null +++ b/backend/src/Designer/Services/Implementation/AppScopesService.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Repository; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Altinn.Studio.Designer.Services.Interfaces; + +namespace Altinn.Studio.Designer.Services.Implementation; + +public class AppScopesService : IAppScopesService +{ + private readonly IAppScopesRepository _appRepository; + private readonly TimeProvider _timeProvider; + + public AppScopesService(IAppScopesRepository appRepository, TimeProvider timeProvider) + { + _appRepository = appRepository; + _timeProvider = timeProvider; + } + + public Task GetAppScopesAsync(AltinnRepoContext context, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return _appRepository.GetAppScopesAsync(context, cancellationToken); + } + + public async Task UpsertScopesAsync(AltinnRepoEditingContext editingContext, + ISet scopes, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var appScopes = await _appRepository.GetAppScopesAsync(editingContext, cancellationToken) ?? + GenerateNewAppScopesEntity(editingContext); + + appScopes.Scopes = scopes; + appScopes.LastModifiedBy = editingContext.Developer; + return await _appRepository.UpsertAppScopesAsync(appScopes, cancellationToken); + } + + private AppScopesEntity GenerateNewAppScopesEntity(AltinnRepoEditingContext context) + { + return new AppScopesEntity + { + Org = context.Org, + App = context.Repo, + CreatedBy = context.Developer, + Created = _timeProvider.GetUtcNow(), + LastModifiedBy = context.Developer, + Scopes = new HashSet() + }; + } +} diff --git a/backend/src/Designer/Services/Interfaces/IAppScopesService.cs b/backend/src/Designer/Services/Interfaces/IAppScopesService.cs new file mode 100644 index 00000000000..f2c7d082af1 --- /dev/null +++ b/backend/src/Designer/Services/Interfaces/IAppScopesService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Repository.Models.AppScope; + +namespace Altinn.Studio.Designer.Services.Interfaces; + +public interface IAppScopesService +{ + Task GetAppScopesAsync(AltinnRepoContext context, CancellationToken cancellationToken = default); + Task UpsertScopesAsync(AltinnRepoEditingContext editingContext, ISet scopes, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Designer/appsettings.json b/backend/src/Designer/appsettings.json index 221a3cf2062..cf846cd73cd 100644 --- a/backend/src/Designer/appsettings.json +++ b/backend/src/Designer/appsettings.json @@ -121,8 +121,8 @@ "CookieExpiryTimeInMinutes" : 1, "AuthorizationDetails": [ { - "Type": "ansattporten:altinn:service", - "Resource":"urn:altinn:resource:2480:40" + "Type": "ansattporten:altinn:service", + "Resource":"urn:altinn:resource:2480:40" }, { "Type": "ansattporten:altinn:service", diff --git a/backend/tests/Designer.Tests/Controllers/ApiTests/ApiTestsBase.cs b/backend/tests/Designer.Tests/Controllers/ApiTests/ApiTestsBase.cs index c2391e6c936..109345e8f49 100644 --- a/backend/tests/Designer.Tests/Controllers/ApiTests/ApiTestsBase.cs +++ b/backend/tests/Designer.Tests/Controllers/ApiTests/ApiTestsBase.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using Designer.Tests.Fixtures; @@ -62,6 +64,7 @@ protected ApiTestsBase(WebApplicationFactory factory) { Factory = factory; SetupDirtyHackIfLinux(); + InitializeJsonConfigOverrides(); } /// @@ -74,7 +77,7 @@ protected virtual HttpClient GetTestClient() string configPath = GetConfigPath(); IConfiguration configuration = new ConfigurationBuilder() .AddJsonFile(configPath, false, false) - .AddJsonStream(GenerateOidcConfigJsonConfig()) + .AddJsonStream(GenerateJsonOverrideConfig()) .AddEnvironmentVariables() .Build(); @@ -84,7 +87,7 @@ protected virtual HttpClient GetTestClient() builder.ConfigureAppConfiguration((_, conf) => { conf.AddJsonFile(configPath); - conf.AddJsonStream(GenerateOidcConfigJsonConfig()); + conf.AddJsonStream(GenerateJsonOverrideConfig()); }); builder.ConfigureTestServices(ConfigureTestServices); builder.ConfigureTestServices(services => @@ -115,9 +118,13 @@ protected virtual void Dispose(bool disposing) { } - protected Stream GenerateOidcConfigJsonConfig() + protected List JsonConfigOverrides; + + private void InitializeJsonConfigOverrides() { - string configOverride = $@" + JsonConfigOverrides = + [ + $@" {{ ""OidcLoginSettings"": {{ ""ClientId"": ""{Guid.NewGuid()}"", @@ -140,8 +147,26 @@ protected Stream GenerateOidcConfigJsonConfig() ""CookieExpiryTimeInMinutes"" : 59 }} }} - "; - var configStream = new MemoryStream(Encoding.UTF8.GetBytes(configOverride)); + " + ]; + } + + + private Stream GenerateJsonOverrideConfig() + { + var overrideJson = Newtonsoft.Json.Linq.JObject.Parse(JsonConfigOverrides.First()); + if (JsonConfigOverrides.Count > 1) + { + foreach (string jsonConfig in JsonConfigOverrides) + { + overrideJson.Merge(Newtonsoft.Json.Linq.JObject.Parse(jsonConfig), new Newtonsoft.Json.Linq.JsonMergeSettings + { + MergeArrayHandling = Newtonsoft.Json.Linq.MergeArrayHandling.Union + }); + } + } + string overrideJsonString = overrideJson.ToString(); + var configStream = new MemoryStream(Encoding.UTF8.GetBytes(overrideJsonString)); configStream.Seek(0, SeekOrigin.Begin); return configStream; } diff --git a/backend/tests/Designer.Tests/Controllers/ApiTests/DbDesignerEndpointsTestsBase.cs b/backend/tests/Designer.Tests/Controllers/ApiTests/DbDesignerEndpointsTestsBase.cs new file mode 100644 index 00000000000..6f52863246c --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/ApiTests/DbDesignerEndpointsTestsBase.cs @@ -0,0 +1,30 @@ +using System; +using Designer.Tests.Fixtures; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Designer.Tests.Controllers.ApiTests +{ + [Trait("Category", "DbIntegrationTest")] + [Collection(nameof(DesignerDbCollection))] + public abstract class DbDesignerEndpointsTestsBase : DesignerEndpointsTestsBase + where TControllerTest : class + { + protected readonly DesignerDbFixture DesignerDbFixture; + + protected DbDesignerEndpointsTestsBase(WebApplicationFactory factory, + DesignerDbFixture designerDbFixture) : base(factory) + { + DesignerDbFixture = designerDbFixture; + JsonConfigOverrides.Add($@" + {{ + ""PostgreSQLSettings"": {{ + ""ConnectionString"": ""{DesignerDbFixture.ConnectionString}"", + ""DesignerDbPwd"": """" + }} + }} + "); + } + + } +} diff --git a/backend/tests/Designer.Tests/Controllers/ApiTests/TestSchemeProvider.cs b/backend/tests/Designer.Tests/Controllers/ApiTests/TestSchemeProvider.cs index a2701804cd6..f2e054f65ec 100644 --- a/backend/tests/Designer.Tests/Controllers/ApiTests/TestSchemeProvider.cs +++ b/backend/tests/Designer.Tests/Controllers/ApiTests/TestSchemeProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Altinn.Studio.Designer; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Options; @@ -21,7 +22,7 @@ protected TestSchemeProvider(IOptions options, IDictionar public override Task GetSchemeAsync(string name) { // Replace cookies scheme used in oidc setup with test scheme - if (name == CookieAuthenticationDefaults.AuthenticationScheme) + if (name is CookieAuthenticationDefaults.AuthenticationScheme or AnsattPortenConstants.AnsattportenAuthenticationScheme) { return base.GetSchemeAsync(TestAuthConstants.TestAuthenticationScheme); } diff --git a/backend/tests/Designer.Tests/Controllers/AppScopesController/Base/AppScopesControllerTestsBase.cs b/backend/tests/Designer.Tests/Controllers/AppScopesController/Base/AppScopesControllerTestsBase.cs new file mode 100644 index 00000000000..4e6e468ae06 --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/AppScopesController/Base/AppScopesControllerTestsBase.cs @@ -0,0 +1,24 @@ +using Designer.Tests.Controllers.ApiTests; +using Designer.Tests.Fixtures; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Designer.Tests.Controllers.AppScopesController.Base; + +public class AppScopesControllerTestsBase : DbDesignerEndpointsTestsBase +where TControllerTest : class +{ + public AppScopesControllerTestsBase(WebApplicationFactory factory, DesignerDbFixture designerDbFixture) : base(factory, designerDbFixture) + { + JsonConfigOverrides.Add($@" + {{ + ""FeatureManagement"": {{ + ""AnsattPorten"": true + }}, + ""AnsattPortenLoginSettings"": {{ + ""ClientId"": ""non-empty-for-testing"", + ""ClientSecret"": ""non-empty-for-testing"" + }} + }} + "); + } +} diff --git a/backend/tests/Designer.Tests/Controllers/AppScopesController/GetAppScopesTests.cs b/backend/tests/Designer.Tests/Controllers/AppScopesController/GetAppScopesTests.cs new file mode 100644 index 00000000000..04c9764b761 --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/AppScopesController/GetAppScopesTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models.Dto; +using Designer.Tests.Controllers.AppScopesController.Base; +using Designer.Tests.DbIntegrationTests; +using Designer.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Designer.Tests.Controllers.AppScopesController; + +public class GetAppScopesTests : AppScopesControllerTestsBase, IClassFixture> +{ + private static string VersionPrefix(string org, string repository) => + $"/designer/api/{org}/{repository}/app-scopes"; + + public GetAppScopesTests(WebApplicationFactory factory, DesignerDbFixture designerDbFixture) : base(factory, designerDbFixture) + { + } + + [Theory] + [InlineData("ttd", "non-existing-app")] + public async Task GetAppScopes_Should_ReturnOk_WithEmptyScopes_IfRecordDoesntExists(string org, string app) + { + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get + , VersionPrefix(org, app)); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + AppScopesResponse repsponseContent = await response.Content.ReadAsAsync(); + repsponseContent.Scopes.Should().BeEmpty(); + } + + [Theory] + [InlineData("ttd", "empty-app")] + public async Task GetAppScopes_Should_ReturnOk_WithScopes_IfRecordExists(string org, string app) + { + var entity = EntityGenerationUtils.AppScopes.GenerateAppScopesEntity(org, app, 4); + await DesignerDbFixture.PrepareAppScopesEntityInDatabaseAsync(entity); + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get + , VersionPrefix(org, app)); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + AppScopesResponse responseContent = await response.Content.ReadAsAsync(); + responseContent.Scopes.Should().HaveCount(4); + + foreach (MaskinPortenScopeDto scope in responseContent.Scopes) + { + entity.Scopes.Should().Contain(x => scope.Scope == x.Scope && scope.Description == x.Description); + } + } +} diff --git a/backend/tests/Designer.Tests/Controllers/AppScopesController/GetScopesFromMaskinPortenTests.cs b/backend/tests/Designer.Tests/Controllers/AppScopesController/GetScopesFromMaskinPortenTests.cs new file mode 100644 index 00000000000..1068af40222 --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/AppScopesController/GetScopesFromMaskinPortenTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models.Dto; +using Designer.Tests.Controllers.AppScopesController.Base; +using Designer.Tests.Controllers.AppScopesController.Utils; +using Designer.Tests.Fixtures; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Designer.Tests.Controllers.AppScopesController; + +public class GetScopesFromMaskinPortenTests : AppScopesControllerTestsBase, IClassFixture>, IClassFixture +{ + private static string VersionPrefix(string org, string repository) => + $"/designer/api/{org}/{repository}/app-scopes/maskinporten"; + + private readonly MockServerFixture _mockServerFixture; + + public GetScopesFromMaskinPortenTests(WebApplicationFactory factory, DesignerDbFixture designerDbFixture, MockServerFixture mockServerFixture) : base(factory, designerDbFixture) + { + _mockServerFixture = mockServerFixture; + JsonConfigOverrides.Add( + $$""" + { + "MaskinPortenHttpClientSettings" : { + "BaseUrl": "{{mockServerFixture.MockApi.Url}}" + } + } + """ + ); + } + + [Theory] + [MemberData(nameof(TestData))] + public async Task GetScopesFromMaskinPortens_Should_ReturnOk(string org, string app, string maskinPortenResponse) + { + _mockServerFixture.PrepareMaskinPortenScopesResponse(maskinPortenResponse); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get + , VersionPrefix(org, app)); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + AppScopesResponse repsponseContent = await response.Content.ReadAsAsync(); + JsonArray array = (JsonArray)JsonNode.Parse(maskinPortenResponse); + repsponseContent.Scopes.Count.Should().Be(array.Count); + } + + + public static IEnumerable TestData() + { + yield return ["ttd", + "non-existing-app", + $@"[ + {{ + ""scope"": ""altinn:demo.torsdag"", + ""state"": ""APPROVED"", + ""created"": ""2024-10-24T08:40:23Z"", + ""description"": ""Dette er en test"", + ""active"": true, + ""consumer_orgno"": ""310461598"", + ""last_updated"": ""2024-10-24T08:40:23Z"", + ""owner_orgno"": ""991825827"", + ""allowed_integration_types"": [ + ""maskinporten"" + ] + }}, + {{ + ""scope"": ""altinn:mirko.dan.test"", + ""state"": ""APPROVED"", + ""created"": ""2024-10-28T11:10:49Z"", + ""description"": ""Dette er bare en test for Altinn Studio integrasjon"", + ""active"": true, + ""consumer_orgno"": ""310461598"", + ""last_updated"": ""2024-10-28T11:10:49Z"", + ""owner_orgno"": ""991825827"", + ""allowed_integration_types"": [ + ""maskinporten"" + ] + }} + ]" + ]; + } + +} diff --git a/backend/tests/Designer.Tests/Controllers/AppScopesController/UpsertAppScopesTests.cs b/backend/tests/Designer.Tests/Controllers/AppScopesController/UpsertAppScopesTests.cs new file mode 100644 index 00000000000..3b02189030f --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/AppScopesController/UpsertAppScopesTests.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models.Dto; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Designer.Tests.Controllers.AppScopesController.Base; +using Designer.Tests.DbIntegrationTests; +using Designer.Tests.Fixtures; +using Designer.Tests.Utils; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Designer.Tests.Controllers.AppScopesController; + +public class UpsertAppScopesTests : AppScopesControllerTestsBase, IClassFixture> +{ + private static string VersionPrefix(string org, string repository) => + $"/designer/api/{org}/{repository}/app-scopes"; + + public UpsertAppScopesTests(WebApplicationFactory factory, DesignerDbFixture designerDbFixture) : base(factory, designerDbFixture) + { + } + + [Theory] + [MemberData(nameof(TestData))] + public async Task UpsertAppScopes_Should_CreateRecordInDb_IfNotExists(string org, string app, AppScopesUpsertRequest payload) + { + await CallUpsertEndpointAndAssertFromDb(org, app, payload); + } + + + [Theory] + [MemberData(nameof(TestData))] + public async Task UpsertAppScopes_Should_UpdateRecordInDb_IfAlreadyExists(string org, string app, AppScopesUpsertRequest payload) + { + var initEntity = EntityGenerationUtils.AppScopes.GenerateAppScopesEntity(org, app, 4); + await DesignerDbFixture.PrepareAppScopesEntityInDatabaseAsync(initEntity); + + await CallUpsertEndpointAndAssertFromDb(org, app, payload); + } + + private async Task CallUpsertEndpointAndAssertFromDb(string org, string app, AppScopesUpsertRequest payload) + { + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Put + , VersionPrefix(org, app)); + httpRequestMessage.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, MediaTypeNames.Application.Json); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var dbEntity = await DesignerDbFixture.DbContext.AppScopes.SingleAsync(x => x.App == app && x.Org == org); + + dbEntity.Should().NotBeNull(); + + var scopes = JsonSerializer.Deserialize>(dbEntity.Scopes, JsonSerializerOptions); + scopes.Should().HaveCount(payload.Scopes.Count); + foreach (MaskinPortenScopeEntity maskinPortenScopeEntity in scopes) + { + payload.Scopes.Should().Contain(x => x.Scope == maskinPortenScopeEntity.Scope && x.Description == maskinPortenScopeEntity.Description); + } + } + + public static IEnumerable TestData() + { + yield return ["ttd", + TestDataHelper.GenerateTestRepoName(), + new AppScopesUpsertRequest() + { + Scopes = new HashSet() + { + new() + { + Scope = "test", + Description = "test" + } + } + }]; + } + +} diff --git a/backend/tests/Designer.Tests/Controllers/AppScopesController/Utils/MaskinPortenMockServerExtensions.cs b/backend/tests/Designer.Tests/Controllers/AppScopesController/Utils/MaskinPortenMockServerExtensions.cs new file mode 100644 index 00000000000..ddf61789eb5 --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/AppScopesController/Utils/MaskinPortenMockServerExtensions.cs @@ -0,0 +1,28 @@ +using System.Net.Mime; +using Designer.Tests.Fixtures; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; + +namespace Designer.Tests.Controllers.AppScopesController.Utils; + +public static class MaskinPortenMockServerExtensions +{ + public static void PrepareMaskinPortenScopesResponse(this MockServerFixture mockServerFixture, string responseJson) + { + var request = Request.Create() + .UsingGet() + .WithPath("/datasharing/consumer/scope/access"); + + var response = Response.Create() + .WithStatusCode(200) + .WithHeader("content-type", MediaTypeNames.Application.Json) + .WithBody(responseJson); + + mockServerFixture.MockApi.Given(request) + .RespondWith( + response + ); + + } + +} diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/GetAppScopesAsyncTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/GetAppScopesAsyncTests.cs new file mode 100644 index 00000000000..8cd7c54919a --- /dev/null +++ b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/GetAppScopesAsyncTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Designer.Tests.Fixtures; +using Designer.Tests.Utils; +using FluentAssertions; +using Xunit; + +namespace Designer.Tests.DbIntegrationTests.AppScopesRepository; + +public class GetAppScopesAsyncTests : DbIntegrationTestsBase +{ + public GetAppScopesAsyncTests(DesignerDbFixture dbFixture) : base(dbFixture) + { + } + + [Theory] + [InlineData("org", "nonexistingapp")] + public async Task GetAppScopesAsync_NoScopesInDb_ReturnsNull(string org, string app) + { + var repository = new Altinn.Studio.Designer.Repository.ORMImplementation.AppScopesRepository(DbFixture.DbContext); + var result = await repository.GetAppScopesAsync(AltinnRepoContext.FromOrgRepo(org, app)); + + result.Should().BeNull(); + } + + + [Theory] + [MemberData(nameof(GetAsyncTestData))] + public async Task GetAppScopesAsync_ReturnExpected(string org, string app, int numberOfScopes) + { + var entity = EntityGenerationUtils.AppScopes.GenerateAppScopesEntity(org, app, numberOfScopes); + await DbFixture.PrepareAppScopesEntityInDatabaseAsync(entity); + var repository = new Altinn.Studio.Designer.Repository.ORMImplementation.AppScopesRepository(DbFixture.DbContext); + AppScopesEntity result = await repository.GetAppScopesAsync(AltinnRepoContext.FromOrgRepo(org, app)); + result.Version.Should().BeGreaterThan(0); + entity.Version = result.Version; + EntityAssertions.AssertEqual(result, entity); + } + + public static IEnumerable GetAsyncTestData() + { + yield return ["ttd", TestDataHelper.GenerateTestRepoName(), 3]; + } +} diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/UpsertAppScopesAsyncIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/UpsertAppScopesAsyncIntegrationTests.cs new file mode 100644 index 00000000000..e09cc05d01a --- /dev/null +++ b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/UpsertAppScopesAsyncIntegrationTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Designer.Tests.Fixtures; +using Designer.Tests.Utils; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Designer.Tests.DbIntegrationTests.AppScopesRepository; + +public class UpsertAppScopesAsyncIntegrationTests : DbIntegrationTestsBase +{ + public UpsertAppScopesAsyncIntegrationTests(DesignerDbFixture dbFixture) : base(dbFixture) + { + } + + [Theory] + [MemberData(nameof(TestData))] + public async Task UpsertAppScopesAsync_ShouldCreateAppScopes_IfNotExists(string org, string app, int numberOfScopes) + { + var entity = EntityGenerationUtils.AppScopes.GenerateAppScopesEntity(org, app, numberOfScopes); + var repository = new Altinn.Studio.Designer.Repository.ORMImplementation.AppScopesRepository(DbFixture.DbContext); + await repository.UpsertAppScopesAsync(entity); + + var dbRecord = await DbFixture.DbContext.AppScopes.AsNoTracking().FirstOrDefaultAsync(d => + d.Org == org && + d.App == app); + + dbRecord.Version.Should().BeGreaterThan(0); + entity.Version = dbRecord.Version; + + EntityAssertions.AssertEqual(entity, dbRecord); + } + + [Theory] + [MemberData(nameof(TestData))] + public async Task UpsertAppScopesAsync_ShouldUpdateAppScopes_IfAlreadyExists(string org, string app, int numberOfScopes) + { + var entity = EntityGenerationUtils.AppScopes.GenerateAppScopesEntity(org, app, numberOfScopes); + await DbFixture.PrepareAppScopesEntityInDatabaseAsync(entity); + + var dbRecord = await DbFixture.DbContext.AppScopes.AsNoTracking().FirstOrDefaultAsync(d => + d.Org == org && + d.App == app); + + dbRecord.Version.Should().BeGreaterThan(0); + entity.Version = dbRecord.Version; + EntityAssertions.AssertEqual(entity, dbRecord); + + entity.Scopes = EntityGenerationUtils.AppScopes.GenerateMaskinPortenScopeEntities(4); + var repository = new Altinn.Studio.Designer.Repository.ORMImplementation.AppScopesRepository(DbFixture.DbContext); + + var result = await repository.UpsertAppScopesAsync(entity); + result.Version.Should().NotBe(entity.Version); + entity.Version = result.Version; + EntityAssertions.AssertEqual(entity, result); + + var updatedDbRecord = await DbFixture.DbContext.AppScopes.AsNoTracking().FirstOrDefaultAsync(d => + d.Org == org && + d.App == app); + + EntityAssertions.AssertEqual(entity, updatedDbRecord); + } + + + public static IEnumerable TestData() + { + yield return ["ttd", TestDataHelper.GenerateTestRepoName(), 3]; + } +} diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/Utils/AppScopesEntityAsserts.cs b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/Utils/AppScopesEntityAsserts.cs new file mode 100644 index 00000000000..0af34fde58d --- /dev/null +++ b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/Utils/AppScopesEntityAsserts.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using FluentAssertions; + +namespace Designer.Tests.DbIntegrationTests; + +public partial class EntityAssertions +{ + public static void AssertEqual(AppScopesEntity appScopesEntity, Altinn.Studio.Designer.Repository.ORMImplementation.Models.AppScopesDbObject dbRecord) + { + dbRecord.App.Should().BeEquivalentTo(appScopesEntity.App); + dbRecord.Org.Should().BeEquivalentTo(appScopesEntity.Org); + dbRecord.CreatedBy.Should().BeEquivalentTo(appScopesEntity.CreatedBy); + dbRecord.LastModifiedBy.Should().BeEquivalentTo(appScopesEntity.LastModifiedBy); + // Allow precision loss up to 100 milliseconds + TimeSpan tolerance = TimeSpan.FromMilliseconds(100); + TimeSpan difference = (appScopesEntity.Created - dbRecord.Created).Duration(); + bool isWithinTolerance = difference <= tolerance; + isWithinTolerance.Should().BeTrue(); + + dbRecord.Version.Should().Be(appScopesEntity.Version); + var scopesFromDb = JsonSerializer.Deserialize>(dbRecord.Scopes, JsonOptions); + scopesFromDb.Should().BeEquivalentTo(appScopesEntity.Scopes); + dbRecord.Version.Should().Be(appScopesEntity.Version); + } + + public static void AssertEqual(AppScopesEntity expected, AppScopesEntity actual) + { + actual.App.Should().Be(expected.App); + actual.Org.Should().Be(expected.Org); + actual.CreatedBy.Should().Be(expected.CreatedBy); + actual.LastModifiedBy.Should().Be(expected.LastModifiedBy); + // Allow precision loss up to 100 milliseconds + TimeSpan tolerance = TimeSpan.FromMilliseconds(100); + TimeSpan difference = (expected.Created - actual.Created).Duration(); + bool isWithinTolerance = difference <= tolerance; + isWithinTolerance.Should().BeTrue(); + + actual.Version.Should().Be(expected.Version); + actual.Scopes.Should().BeEquivalentTo(expected.Scopes); + } + +} diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/Utils/EntityGenerationUtils.cs b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/Utils/EntityGenerationUtils.cs new file mode 100644 index 00000000000..0e61a203f83 --- /dev/null +++ b/backend/tests/Designer.Tests/DbIntegrationTests/AppScopesRepository/Utils/EntityGenerationUtils.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Altinn.Studio.Designer.Repository.Models.AppScope; + +namespace Designer.Tests.DbIntegrationTests; + +public static partial class EntityGenerationUtils +{ + public static class AppScopes + { + public static AppScopesEntity GenerateAppScopesEntity(string org, string app = null, int numberOfScopes = 3, string developer = "testUser") + { + return new AppScopesEntity + { + Org = org, + App = app ?? Guid.NewGuid().ToString(), + CreatedBy = developer, + LastModifiedBy = developer, + Created = DateTimeOffset.UtcNow, + Scopes = GenerateMaskinPortenScopeEntities(numberOfScopes), + }; + } + + public static MaskinPortenScopeEntity GenerateMaskinPortenScopeEntity() => + new() + { + Scope = Guid.NewGuid().ToString(), + Description = Guid.NewGuid().ToString(), + }; + + public static ISet GenerateMaskinPortenScopeEntities(int count) => + Enumerable.Range(0, count) + .Select(x => GenerateMaskinPortenScopeEntity()).ToHashSet(); + } + +} diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/CreateIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/CreateIntegrationTests.cs index 98a104d7284..6d9e0a01f8f 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/CreateIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/CreateIntegrationTests.cs @@ -20,7 +20,7 @@ public async Task Create_ShouldInsertRecordInDatabase(string org) { var repository = new ORMDeploymentRepository(DbFixture.DbContext); var buildId = Guid.NewGuid(); - var deploymentEntity = EntityGenerationUtils.GenerateDeploymentEntity(org, buildId: buildId.ToString()); + var deploymentEntity = EntityGenerationUtils.Deployment.GenerateDeploymentEntity(org, buildId: buildId.ToString()); await repository.Create(deploymentEntity); var dbRecord = await DbFixture.DbContext.Deployments.AsNoTracking().FirstOrDefaultAsync(d => d.Org == org && diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetIntegrationTests.cs index 7d0daf8618d..d18bee09bcb 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetIntegrationTests.cs @@ -23,7 +23,7 @@ public GetIntegrationTests(DesignerDbFixture dbFixture) : base(dbFixture) public async Task Get_ShouldReturnCorrectRecordsFromDatabase(string org, string app, int top, SortDirection sortDirection) { int allEntitiesCount = 10; - var deploymentEntities = EntityGenerationUtils.GenerateDeploymentEntities(org, app, allEntitiesCount).ToList(); + var deploymentEntities = EntityGenerationUtils.Deployment.GenerateDeploymentEntities(org, app, allEntitiesCount).ToList(); await PrepareEntitiesInDatabase(deploymentEntities); var repository = new ORMDeploymentRepository(DbFixture.DbContext); @@ -46,7 +46,7 @@ public async Task Get_Without_TopDefined_ShouldReturnAllRecordsForGivenApp(strin SortDirection sortDirection) { int allEntitiesCount = 10; - var deploymentEntities = EntityGenerationUtils.GenerateDeploymentEntities(org, app, allEntitiesCount).ToList(); + var deploymentEntities = EntityGenerationUtils.Deployment.GenerateDeploymentEntities(org, app, allEntitiesCount).ToList(); await PrepareEntitiesInDatabase(deploymentEntities); var repository = new ORMDeploymentRepository(DbFixture.DbContext); diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetSingleIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetSingleIntegrationTests.cs index 92955bc3dbd..30d738ba0ca 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetSingleIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/GetSingleIntegrationTests.cs @@ -17,7 +17,7 @@ public GetSingleIntegrationTests(DesignerDbFixture dbFixture) : base(dbFixture) [InlineData("ttd")] public async Task Get_ShouldReturnRecordFromDatabase(string org) { - var deploymentEntity = EntityGenerationUtils.GenerateDeploymentEntity(org); + var deploymentEntity = EntityGenerationUtils.Deployment.GenerateDeploymentEntity(org); await PrepareEntityInDatabase(deploymentEntity); var repository = new ORMDeploymentRepository(DbFixture.DbContext); diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs index 06101cc7e59..dee2f35baac 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs @@ -24,7 +24,7 @@ public async Task CreateOld_ShouldBeCompatibleWithNewGet(string org) { var oldRepository = CreateOldRepository(); string buildId = Guid.NewGuid().ToString(); - var deploymentEntity = EntityGenerationUtils.GenerateDeploymentEntity(org, buildId: buildId); + var deploymentEntity = EntityGenerationUtils.Deployment.GenerateDeploymentEntity(org, buildId: buildId); await oldRepository.Create(deploymentEntity); var newRepository = new ORMDeploymentRepository(DbFixture.DbContext); var queriedEntity = await newRepository.Get(org, buildId); @@ -37,7 +37,7 @@ public async Task CreateNew_ShouldBeCompatibleWithOldGet(string org) { var newRepository = new ORMDeploymentRepository(DbFixture.DbContext); string buildId = Guid.NewGuid().ToString(); - var deploymentEntity = EntityGenerationUtils.GenerateDeploymentEntity(org, buildId: buildId); + var deploymentEntity = EntityGenerationUtils.Deployment.GenerateDeploymentEntity(org, buildId: buildId); await newRepository.Create(deploymentEntity); var oldRepository = CreateOldRepository(); var queriedEntity = await oldRepository.Get(org, buildId); diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/UpdateIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/UpdateIntegrationTests.cs index 257f2b89d0d..f5d959258e4 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/UpdateIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/UpdateIntegrationTests.cs @@ -21,7 +21,7 @@ public async Task Update_ShouldUpdateRecordInDatabase(string org) { var repository = new ORMDeploymentRepository(DbFixture.DbContext); var buildId = Guid.NewGuid(); - var deploymentEntity = EntityGenerationUtils.GenerateDeploymentEntity( + var deploymentEntity = EntityGenerationUtils.Deployment.GenerateDeploymentEntity( org, buildId: buildId.ToString(), buildStatus: BuildStatus.InProgress, diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/Utils/EntityGenerationUtils.cs b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/Utils/EntityGenerationUtils.cs index 7df8c234ab2..9154fbd3405 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/Utils/EntityGenerationUtils.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/DeploymentEntityRepository/Utils/EntityGenerationUtils.cs @@ -8,22 +8,26 @@ namespace Designer.Tests.DbIntegrationTests; public static partial class EntityGenerationUtils { - public static DeploymentEntity GenerateDeploymentEntity(string org, string app = null, string buildId = null, string tagname = null, BuildStatus buildStatus = BuildStatus.Completed, BuildResult buildResult = BuildResult.Succeeded) + public static class Deployment { - BuildEntity build = GenerateBuildEntity(buildId, buildStatus, buildResult); - - return new DeploymentEntity + public static DeploymentEntity GenerateDeploymentEntity(string org, string app = null, string buildId = null, string tagname = null, BuildStatus buildStatus = BuildStatus.Completed, BuildResult buildResult = BuildResult.Succeeded) { - Org = org, - App = app ?? Guid.NewGuid().ToString(), - Build = build, - TagName = tagname ?? Guid.NewGuid().ToString(), - EnvName = Guid.NewGuid().ToString(), - Created = DateTime.UtcNow, - }; + BuildEntity build = Build.GenerateBuildEntity(buildId, buildStatus, buildResult); + + return new DeploymentEntity + { + Org = org, + App = app ?? Guid.NewGuid().ToString(), + Build = build, + TagName = tagname ?? Guid.NewGuid().ToString(), + EnvName = Guid.NewGuid().ToString(), + Created = DateTime.UtcNow, + }; + } + + public static IEnumerable GenerateDeploymentEntities(string org, string app, int count) => + Enumerable.Range(0, count) + .Select(x => GenerateDeploymentEntity(org, app)).ToList(); } - public static IEnumerable GenerateDeploymentEntities(string org, string app, int count) => - Enumerable.Range(0, count) - .Select(x => GenerateDeploymentEntity(org, app)).ToList(); } diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/EntityGenerationUtils.cs b/backend/tests/Designer.Tests/DbIntegrationTests/EntityGenerationUtils.cs index 87aa86a7cf1..821c67246ee 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/EntityGenerationUtils.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/EntityGenerationUtils.cs @@ -6,15 +6,19 @@ namespace Designer.Tests.DbIntegrationTests; public static partial class EntityGenerationUtils { - public static BuildEntity GenerateBuildEntity(string buildId = null, BuildStatus buildStatus = BuildStatus.Completed, BuildResult buildResult = BuildResult.Succeeded, DateTime? finished = null) + public static partial class Build { - return new BuildEntity + public static BuildEntity GenerateBuildEntity(string buildId = null, BuildStatus buildStatus = BuildStatus.Completed, BuildResult buildResult = BuildResult.Succeeded, DateTime? finished = null) { - Id = buildId ?? Guid.NewGuid().ToString(), - Status = buildStatus, - Result = buildResult, - Started = DateTime.UtcNow, - Finished = finished - }; + return new BuildEntity + { + Id = buildId ?? Guid.NewGuid().ToString(), + Status = buildStatus, + Result = buildResult, + Started = DateTime.UtcNow, + Finished = finished + }; + } } + } diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/CreateIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/CreateIntegrationTests.cs index 18df4f4a1ea..413843e0e30 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/CreateIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/CreateIntegrationTests.cs @@ -20,7 +20,7 @@ public async Task Create_ShouldInsertRecordInDatabase(string org) { var repository = new ORMReleaseRepository(DbFixture.DbContext); var buildId = Guid.NewGuid(); - var releaseEntity = EntityGenerationUtils.GenerateReleaseEntity(org, buildId: buildId.ToString()); + var releaseEntity = EntityGenerationUtils.Release.GenerateReleaseEntity(org, buildId: buildId.ToString()); await repository.Create(releaseEntity); var dbRecord = await DbFixture.DbContext.Releases.AsNoTracking().FirstOrDefaultAsync(d => d.Org == org && diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetBuildStatusAndResultsFilterIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetBuildStatusAndResultsFilterIntegrationTests.cs index a9934be443d..fac5e9caa45 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetBuildStatusAndResultsFilterIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetBuildStatusAndResultsFilterIntegrationTests.cs @@ -24,7 +24,7 @@ public async Task Get_ShouldReturnCorrectRecordsFromDatabase(string org, string int numberOfEntities = statusReleaseCombinationsInDb.Count; string tagName = Guid.NewGuid().ToString(); var repository = new ORMReleaseRepository(DbFixture.DbContext); - var releaseEntities = EntityGenerationUtils.GenerateReleaseEntities(org, app, numberOfEntities).ToList(); + var releaseEntities = EntityGenerationUtils.Release.GenerateReleaseEntities(org, app, numberOfEntities).ToList(); for (int i = 0; i < numberOfEntities; i++) { releaseEntities[i].TagName = tagName; diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSingleIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSingleIntegrationTests.cs index f629d194a02..6a9369149e8 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSingleIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSingleIntegrationTests.cs @@ -21,7 +21,7 @@ public async Task GetSingleAsync_WhenCalled_ShouldReturnSingleReleaseEntity(stri { var repository = new ORMReleaseRepository(DbFixture.DbContext); var buildId = Guid.NewGuid(); - var releaseEntity = EntityGenerationUtils.GenerateReleaseEntity(releaseName, buildId: buildId.ToString()); + var releaseEntity = EntityGenerationUtils.Release.GenerateReleaseEntity(releaseName, buildId: buildId.ToString()); await PrepareEntityInDatabase(releaseEntity); var result = (await repository.Get(releaseEntity.Org, buildId.ToString())).Single(); diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSucceededReleaseFromDbIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSucceededReleaseFromDbIntegrationTests.cs index 42d86f5486a..eefbe351723 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSucceededReleaseFromDbIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetSucceededReleaseFromDbIntegrationTests.cs @@ -24,7 +24,7 @@ public async Task Get_ShouldReturnCorrectRecordsFromDatabase(string org, string int numberOfEntities = statusReleaseCombinationsInDb.Count; string tagName = Guid.NewGuid().ToString(); var repository = new ORMReleaseRepository(DbFixture.DbContext); - var releaseEntities = EntityGenerationUtils.GenerateReleaseEntities(org, app, numberOfEntities).ToList(); + var releaseEntities = EntityGenerationUtils.Release.GenerateReleaseEntities(org, app, numberOfEntities).ToList(); for (int i = 0; i < numberOfEntities; i++) { releaseEntities[i].TagName = tagName; diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetTopAndSortedIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetTopAndSortedIntegrationTests.cs index b6a7c5dabf2..a83c9d57984 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetTopAndSortedIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/GetTopAndSortedIntegrationTests.cs @@ -23,7 +23,7 @@ public GetTopAndSortedIntegrationTests(DesignerDbFixture dbFixture) : base(dbFix public async Task Get_ShouldReturnCorrectRecordsFromDatabase(string org, string app, int top, SortDirection sortDirection) { int allEntitiesCount = 10; - var releaseEntities = EntityGenerationUtils.GenerateReleaseEntities(org, app, allEntitiesCount).ToList(); + var releaseEntities = EntityGenerationUtils.Release.GenerateReleaseEntities(org, app, allEntitiesCount).ToList(); await PrepareEntitiesInDatabase(releaseEntities); var repository = new ORMReleaseRepository(DbFixture.DbContext); @@ -46,7 +46,7 @@ public async Task Get_Without_TopDefined_ShouldReturnAllRecordsForGivenApp(strin SortDirection sortDirection) { int allEntitiesCount = 10; - var releaseEntities = EntityGenerationUtils.GenerateReleaseEntities(org, app, allEntitiesCount).ToList(); + var releaseEntities = EntityGenerationUtils.Release.GenerateReleaseEntities(org, app, allEntitiesCount).ToList(); await PrepareEntitiesInDatabase(releaseEntities); var repository = new ORMReleaseRepository(DbFixture.DbContext); diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs index cde8bb5d174..36d4b02d55a 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/OldRepositoryCompatibility/CreateGetCompatibilityTests.cs @@ -25,7 +25,7 @@ public async Task CreateOld_ShouldBeCompatibleWithNewGet(string org) { var oldRepository = CreateOldRepository(); string buildId = Guid.NewGuid().ToString(); - var releaseEntity = EntityGenerationUtils.GenerateReleaseEntity(org, buildId: buildId); + var releaseEntity = EntityGenerationUtils.Release.GenerateReleaseEntity(org, buildId: buildId); await oldRepository.Create(releaseEntity); var newRepository = new ORMReleaseRepository(DbFixture.DbContext); var queriedEntity = (await newRepository.Get(org, buildId)).Single(); @@ -38,7 +38,7 @@ public async Task CreateNew_ShouldBeCompatibleWithOldGet(string org) { var newRepository = new ORMReleaseRepository(DbFixture.DbContext); string buildId = Guid.NewGuid().ToString(); - var releaseEntity = EntityGenerationUtils.GenerateReleaseEntity(org, buildId: buildId); + var releaseEntity = EntityGenerationUtils.Release.GenerateReleaseEntity(org, buildId: buildId); await newRepository.Create(releaseEntity); var oldRepository = CreateOldRepository(); var queriedEntity = (await oldRepository.Get(org, buildId)).Single(); diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/UpdateIntegrationTests.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/UpdateIntegrationTests.cs index cda12da85b7..b4393958d8d 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/UpdateIntegrationTests.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/UpdateIntegrationTests.cs @@ -22,7 +22,7 @@ public async Task UpdateReleaseEntityAsync_WhenCalled_ShouldUpdateReleaseEntity( { var repository = new ORMReleaseRepository(DbFixture.DbContext); var buildId = Guid.NewGuid(); - var releaseEntity = EntityGenerationUtils.GenerateReleaseEntity(releaseName, buildId: buildId.ToString()); + var releaseEntity = EntityGenerationUtils.Release.GenerateReleaseEntity(releaseName, buildId: buildId.ToString()); await PrepareEntityInDatabase(releaseEntity); releaseEntity.Build.Finished = DateTime.UtcNow; diff --git a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/Utils/EntityGenerationUtils.cs b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/Utils/EntityGenerationUtils.cs index 504f77aa0d5..d6093eaa3f7 100644 --- a/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/Utils/EntityGenerationUtils.cs +++ b/backend/tests/Designer.Tests/DbIntegrationTests/ReleaseEntityRepository/Utils/EntityGenerationUtils.cs @@ -8,23 +8,26 @@ namespace Designer.Tests.DbIntegrationTests; public static partial class EntityGenerationUtils { - public static ReleaseEntity GenerateReleaseEntity(string org, string app = null, string buildId = null, string body = "build message", string targetCommitish = null, string tagname = null, BuildStatus buildStatus = BuildStatus.Completed, BuildResult buildResult = BuildResult.Succeeded) + public static class Release { - BuildEntity build = GenerateBuildEntity(buildId, buildStatus, buildResult); - - return new ReleaseEntity + public static ReleaseEntity GenerateReleaseEntity(string org, string app = null, string buildId = null, string body = "build message", string targetCommitish = null, string tagname = null, BuildStatus buildStatus = BuildStatus.Completed, BuildResult buildResult = BuildResult.Succeeded) { - Org = org, - App = app ?? Guid.NewGuid().ToString(), - Build = build, - TagName = tagname ?? Guid.NewGuid().ToString(), - Created = DateTime.UtcNow, - TargetCommitish = targetCommitish ?? Guid.NewGuid().ToString(), - Body = body - }; - } + BuildEntity build = Build.GenerateBuildEntity(buildId, buildStatus, buildResult); - public static IEnumerable GenerateReleaseEntities(string org, string app, int count) => - Enumerable.Range(0, count) - .Select(x => GenerateReleaseEntity(org, app)).ToList(); + return new ReleaseEntity + { + Org = org, + App = app ?? Guid.NewGuid().ToString(), + Build = build, + TagName = tagname ?? Guid.NewGuid().ToString(), + Created = DateTime.UtcNow, + TargetCommitish = targetCommitish ?? Guid.NewGuid().ToString(), + Body = body + }; + } + + public static IEnumerable GenerateReleaseEntities(string org, string app, int count) => + Enumerable.Range(0, count) + .Select(x => GenerateReleaseEntity(org, app)).ToList(); + } } diff --git a/backend/tests/Designer.Tests/Designer.Tests.csproj b/backend/tests/Designer.Tests/Designer.Tests.csproj index 80927453761..84b0614d466 100644 --- a/backend/tests/Designer.Tests/Designer.Tests.csproj +++ b/backend/tests/Designer.Tests/Designer.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/backend/tests/Designer.Tests/Fixtures/DesignerDbFixture.cs b/backend/tests/Designer.Tests/Fixtures/DesignerDbFixture.cs index 3de4cafffa0..67b34fb45c1 100644 --- a/backend/tests/Designer.Tests/Fixtures/DesignerDbFixture.cs +++ b/backend/tests/Designer.Tests/Fixtures/DesignerDbFixture.cs @@ -27,7 +27,7 @@ public async Task InitializeAsync() var options = CreatePostgresDbContextOptions(); DbContext = new DesignerdbContext(options); - // Migration scripts except deisgner role to exist. + // Migration scripts expect deisgner role to exist. await DbContext.Database.ExecuteSqlAsync($"CREATE ROLE designer WITH LOGIN PASSWORD 'Test1234$'"); await DbContext.Database.MigrateAsync(); } diff --git a/backend/tests/Designer.Tests/Fixtures/DesignerDbFixtureExtensions.cs b/backend/tests/Designer.Tests/Fixtures/DesignerDbFixtureExtensions.cs new file mode 100644 index 00000000000..667f3f094bc --- /dev/null +++ b/backend/tests/Designer.Tests/Fixtures/DesignerDbFixtureExtensions.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Repository.Models.AppScope; +using Microsoft.EntityFrameworkCore; + +namespace Designer.Tests.Fixtures; + +public static class DesignerDbFixtureExtensions +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() } + }; + + public static async Task PrepareAppScopesEntityInDatabaseAsync(this DesignerDbFixture dbFixture, AppScopesEntity entity) + { + var dbObject = MapToDbObject(entity); + + await dbFixture.DbContext.AppScopes.AddAsync(dbObject); + await dbFixture.DbContext.SaveChangesAsync(); + dbFixture.DbContext.Entry(dbObject).State = EntityState.Detached; + } + + + private static Altinn.Studio.Designer.Repository.ORMImplementation.Models.AppScopesDbObject MapToDbObject(AppScopesEntity entity) => + new() + { + App = entity.App, + Org = entity.Org, + Created = entity.Created, + Scopes = JsonSerializer.Serialize(entity.Scopes, s_jsonOptions), + CreatedBy = entity.CreatedBy, + LastModifiedBy = entity.LastModifiedBy + }; + +} diff --git a/backend/tests/Designer.Tests/Fixtures/MockServerFixture.cs b/backend/tests/Designer.Tests/Fixtures/MockServerFixture.cs new file mode 100644 index 00000000000..3855ace2872 --- /dev/null +++ b/backend/tests/Designer.Tests/Fixtures/MockServerFixture.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using WireMock.Server; +using Xunit; + +namespace Designer.Tests.Fixtures; + +public class MockServerFixture : IAsyncLifetime +{ + public WireMockServer MockApi; + + public async Task InitializeAsync() + { + await Task.CompletedTask; + int availablePort = TestUrlsProvider.GetRandomAvailablePort(); + MockApi = WireMockServer.Start(availablePort); + } + + public async Task DisposeAsync() + { + await Task.CompletedTask; + MockApi?.Stop(); + } +} diff --git a/backend/tests/Designer.Tests/Fixtures/TestUrlsProvider.cs b/backend/tests/Designer.Tests/Fixtures/TestUrlsProvider.cs index e586c809ad1..d3710127b19 100644 --- a/backend/tests/Designer.Tests/Fixtures/TestUrlsProvider.cs +++ b/backend/tests/Designer.Tests/Fixtures/TestUrlsProvider.cs @@ -25,7 +25,7 @@ private TestUrlsProvider() } - private static int GetRandomAvailablePort() + public static int GetRandomAvailablePort() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); diff --git a/frontend/dashboard/components/Resources/Resources.tsx b/frontend/dashboard/components/Resources/Resources.tsx index e74d6b57bca..fbc8f8fe173 100644 --- a/frontend/dashboard/components/Resources/Resources.tsx +++ b/frontend/dashboard/components/Resources/Resources.tsx @@ -64,9 +64,9 @@ const resources: Resource[] = [ ), }, { - label: 'dashboard.resource_slack_label', - description: 'dashboard.resource_slack_description', - url: 'https://altinn.slack.com', + label: 'dashboard.resource_contact_label', + description: 'dashboard.resource_contact_description', + url: 'https://altinn.studio/contact', icon: ( { + it('should render the component with icon', () => { + renderTestAlert({ iconTitle: 'test-icon-title' }); + const studioAlert = screen.getByRole('img', { name: 'test-icon-title' }); + expect(studioAlert).toBeInTheDocument(); + }); + + it('should support forwarding the ref', () => { + testRefForwarding((ref) => renderTestAlert({}, ref)); + }); + + it('should append classname to root', () => { + testRootClassNameAppending((className) => renderTestAlert({ className })); + }); + + it('should allow custom attributes', () => { + testCustomAttributes((customAttributes) => renderTestAlert({ ...customAttributes })); + }); +}); + +const renderTestAlert = ( + props: Partial = {}, + ref?: ForwardedRef, +) => { + return render(); +}; diff --git a/frontend/libs/studio-components/src/components/StudioAlert/StudioAlert.tsx b/frontend/libs/studio-components/src/components/StudioAlert/StudioAlert.tsx new file mode 100644 index 00000000000..8a57be4c53c --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioAlert/StudioAlert.tsx @@ -0,0 +1,14 @@ +import React, { forwardRef } from 'react'; +import { Alert, type AlertProps } from '@digdir/designsystemet-react'; + +export type StudioAlertProps = AlertProps; + +const StudioAlert = forwardRef( + ({ size = 'sm', ...rest }, ref) => { + return ; + }, +); + +StudioAlert.displayName = 'StudioAlert'; + +export { StudioAlert }; diff --git a/frontend/libs/studio-components/src/components/StudioAlert/index.ts b/frontend/libs/studio-components/src/components/StudioAlert/index.ts new file mode 100644 index 00000000000..4558f3979fb --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioAlert/index.ts @@ -0,0 +1 @@ +export { StudioAlert } from './StudioAlert'; diff --git a/frontend/libs/studio-components/src/components/StudioAlert/index.tsx b/frontend/libs/studio-components/src/components/StudioAlert/index.tsx deleted file mode 100644 index 2946aec9c00..00000000000 --- a/frontend/libs/studio-components/src/components/StudioAlert/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { Alert as StudioAlert } from '@digdir/designsystemet-react'; diff --git a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css index d2c491f06f4..71b154d5cb5 100644 --- a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css +++ b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.module.css @@ -7,5 +7,5 @@ .description { margin-top: var(--fds-spacing-2); - margin-bottom: var(--fds-spacing-4); + margin-bottom: var(--fds-spacing-1); } diff --git a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx index 12313cb3cfa..5bf6ed75fde 100644 --- a/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx +++ b/frontend/libs/studio-components/src/components/StudioRecommendedNextAction/StudioRecommendedNextAction.tsx @@ -10,8 +10,8 @@ export type StudioRecommendedNextActionProps = { saveButtonText?: string; onSkip?: React.MouseEventHandler; skipButtonText?: string; - title: string; - description: string; + title?: string; + description?: string; hideSaveButton?: boolean; hideSkipButton?: boolean; children: React.ReactNode; diff --git a/frontend/libs/studio-hooks/package.json b/frontend/libs/studio-hooks/package.json index f4ff62184e8..46fbd7aad54 100644 --- a/frontend/libs/studio-hooks/package.json +++ b/frontend/libs/studio-hooks/package.json @@ -22,5 +22,8 @@ "jest-environment-jsdom": "^29.7.0", "ts-jest": "^29.1.1", "typescript": "5.6.2" + }, + "peerDependencies": { + "react-router-dom": ">=6.0.0" } } diff --git a/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.test.tsx b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.test.tsx new file mode 100644 index 00000000000..760240946b5 --- /dev/null +++ b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.test.tsx @@ -0,0 +1,69 @@ +import { renderHook } from '@testing-library/react'; +import { type UseOrgAppScopedStorage, useOrgAppScopedStorage } from './useOrgAppScopedStorage'; +import { useParams } from 'react-router-dom'; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn(), +})); + +const mockedOrg: string = 'testOrg'; +const mockedApp: string = 'testApp'; +const scopedStorageKey: string = 'testOrg-testApp'; +const storagesToTest: Array = ['localStorage', 'sessionStorage']; + +describe('useOrgAppScopedStorage', () => { + afterEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it.each(storagesToTest)( + 'initializes ScopedStorageImpl with correct storage scope, %s', + (storage) => { + const { result } = renderUseOrgAppScopedStorage({ storage }); + + result.current.setItem('key', 'value'); + + expect(result.current.setItem).toBeDefined(); + expect(result.current.getItem).toBeDefined(); + expect(result.current.removeItem).toBeDefined(); + expect(window[storage].getItem(scopedStorageKey)).toBe('{"key":"value"}'); + }, + ); + + it.each(storagesToTest)('should retrieve parsed objects from %s', (storage) => { + const { result } = renderUseOrgAppScopedStorage({ storage }); + + result.current.setItem('person', { name: 'John', age: 18 }); + + expect(result.current.getItem('person')).toEqual({ + name: 'John', + age: 18, + }); + }); + + it.each(storagesToTest)('should be possible to remove item from %s', (storage) => { + const { result } = renderUseOrgAppScopedStorage({ storage }); + + result.current.setItem('key', 'value'); + result.current.removeItem('key'); + expect(result.current.getItem('key')).toBeUndefined(); + }); + + it('should use localStorage as default storage', () => { + const { result } = renderUseOrgAppScopedStorage({}); + result.current.setItem('key', 'value'); + + expect(window.localStorage.getItem(scopedStorageKey)).toBe('{"key":"value"}'); + }); +}); + +const renderUseOrgAppScopedStorage = ({ storage }: UseOrgAppScopedStorage) => { + (useParams as jest.Mock).mockReturnValue({ org: mockedOrg, app: mockedApp }); + const { result } = renderHook(() => + useOrgAppScopedStorage({ + storage, + }), + ); + return { result }; +}; diff --git a/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts new file mode 100644 index 00000000000..b9582cdba59 --- /dev/null +++ b/frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts @@ -0,0 +1,36 @@ +import { useParams } from 'react-router-dom'; +import { + type ScopedStorage, + type ScopedStorageResult, + ScopedStorageImpl, +} from '@studio/pure-functions'; + +type OrgAppParams = { + org: string; + app: string; +}; + +const supportedStorageMap: Record = { + localStorage: window.localStorage, + sessionStorage: window.sessionStorage, +}; + +export type UseOrgAppScopedStorage = { + storage?: 'localStorage' | 'sessionStorage'; +}; + +type UseOrgAppScopedStorageResult = ScopedStorageResult; +export const useOrgAppScopedStorage = ({ + storage = 'localStorage', +}: UseOrgAppScopedStorage): UseOrgAppScopedStorageResult => { + const { org, app } = useParams(); + + const storageKey: string = `${org}-${app}`; + const scopedStorage = new ScopedStorageImpl(supportedStorageMap[storage], storageKey); + + return { + setItem: scopedStorage.setItem, + getItem: scopedStorage.getItem, + removeItem: scopedStorage.removeItem, + }; +}; diff --git a/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts b/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts index ffbaaea8996..0fc48637a6f 100644 --- a/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts +++ b/frontend/libs/studio-pure-functions/src/ScopedStorage/ScopedStorage.ts @@ -2,6 +2,12 @@ type StorageKey = string; export interface ScopedStorage extends Pick {} +export interface ScopedStorageResult extends ScopedStorage { + setItem: (key: string, value: T) => void; + getItem: (key: string) => T; + removeItem: (key: string) => void; +} + export class ScopedStorageImpl implements ScopedStorage { private readonly storageKey: StorageKey; private readonly scopedStorage: ScopedStorage; @@ -12,6 +18,9 @@ export class ScopedStorageImpl implements ScopedStorage { ) { this.storageKey = this.key; this.scopedStorage = this.storage; + this.setItem = this.setItem.bind(this); + this.getItem = this.getItem.bind(this); + this.removeItem = this.removeItem.bind(this); } public setItem(key: string, value: T): void { diff --git a/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts b/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts index 999823d8a7a..0b4b3d5747c 100644 --- a/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts +++ b/frontend/libs/studio-pure-functions/src/ScopedStorage/index.ts @@ -1 +1 @@ -export { ScopedStorageImpl, type ScopedStorage } from './ScopedStorage'; +export { ScopedStorageImpl, type ScopedStorage, type ScopedStorageResult } from './ScopedStorage'; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.module.css b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.module.css new file mode 100644 index 00000000000..7def1fa7574 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.module.css @@ -0,0 +1,10 @@ +.savelayoutSetButton { + display: flex; + align-self: flex-start; + border: 2px solid var(--success-color); + color: var(--success-color); +} + +.headerIcon { + font-size: large; +} diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx new file mode 100644 index 00000000000..970ce181232 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { renderWithProviders } from '../../../../../../testing/mocks'; +import { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { screen, waitFor } from '@testing-library/react'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { app, org } from '@studio/testing/testids'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { layoutSets } from 'app-shared/mocks/mocks'; +import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import userEvent from '@testing-library/user-event'; +import type { FormComponent } from '../../../../../../types/FormComponent'; +import { AppContext } from '../../../../../../AppContext'; +import { appContextMock } from '../../../../../../testing/appContextMock'; + +const onSubFormCreatedMock = jest.fn(); + +describe('CreateNewSubformLayoutSet ', () => { + afterEach(jest.clearAllMocks); + + it('displays the card with label and input field', () => { + renderCreateNewSubformLayoutSet(); + const card = screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.subform.created_layout_set_name'), + }); + + expect(card).toBeInTheDocument(); + }); + + it('displays the input field', () => { + renderCreateNewSubformLayoutSet(); + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + }); + + it('displays the save button', () => { + renderCreateNewSubformLayoutSet(); + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + expect(saveButton).toBeInTheDocument(); + }); + + it('calls onSubFormCreated when save button is clicked', async () => { + const user = userEvent.setup(); + renderCreateNewSubformLayoutSet(); + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubForm'); + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + await user.click(saveButton); + await waitFor(() => expect(onSubFormCreatedMock).toHaveBeenCalledTimes(1)); + expect(onSubFormCreatedMock).toHaveBeenCalledWith('NewSubForm'); + }); +}); + +const renderCreateNewSubformLayoutSet = ( + layoutSetsMock: LayoutSets = layoutSets, + componentProps: Partial> = {}, +) => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); + return renderWithProviders( + + + , + { queryClient }, + ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx new file mode 100644 index 00000000000..ef9b52de926 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioButton, StudioCard, StudioTextfield } from '@studio/components'; +import { ClipboardIcon, CheckmarkIcon } from '@studio/icons'; +import { useAddLayoutSetMutation } from 'app-development/hooks/mutations/useAddLayoutSetMutation'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import classes from './CreateNewSubformLayoutSet.module.css'; + +type CreateNewSubformLayoutSetProps = { + onSubFormCreated: (layoutSetName: string) => void; +}; + +export const CreateNewSubformLayoutSet = ({ + onSubFormCreated, +}: CreateNewSubformLayoutSetProps): React.ReactElement => { + const { t } = useTranslation(); + const [newSubForm, setNewSubForm] = useState(''); + const { org, app } = useStudioEnvironmentParams(); + const { mutate: addLayoutSet } = useAddLayoutSetMutation(org, app); + + const createNewSubform = () => { + if (!newSubForm) return; + addLayoutSet({ + layoutSetIdToUpdate: newSubForm, + layoutSetConfig: { + id: newSubForm, + type: 'subform', + }, + }); + onSubFormCreated(newSubForm); + setNewSubForm(''); + }; + + function handleChange(e: React.ChangeEvent) { + setNewSubForm(e.target.value); + } + + return ( + + + + + + + } + onClick={createNewSubform} + title={t('general.close')} + variant='tertiary' + color='success' + /> + + + ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts new file mode 100644 index 00000000000..39c8808d341 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts @@ -0,0 +1 @@ +export { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.module.css b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.module.css new file mode 100644 index 00000000000..cec24eef80a --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.module.css @@ -0,0 +1,4 @@ +.button { + padding-left: 0; + border-radius: var(--fds-sizing-1); +} diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx index 84200aeaa3e..6968f45919a 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/EditLayoutSet.tsx @@ -2,19 +2,29 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DefinedLayoutSet } from './DefinedLayoutSet/DefinedLayoutSet'; import { SelectLayoutSet } from './SelectLayoutSet/SelectLayoutSet'; -import { StudioRecommendedNextAction } from '@studio/components'; +import { StudioParagraph, StudioProperty, StudioRecommendedNextAction } from '@studio/components'; +import { PlusIcon } from '@studio/icons'; +import classes from './EditLayoutSet.module.css'; +import { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; type EditLayoutSetProps = { existingLayoutSetForSubform: string; onUpdateLayoutSet: (layoutSetId: string) => void; + onSubFormCreated: (layoutSetName: string) => void; }; export const EditLayoutSet = ({ existingLayoutSetForSubform, onUpdateLayoutSet, + onSubFormCreated, }: EditLayoutSetProps): React.ReactElement => { const { t } = useTranslation(); const [isLayoutSetSelectorVisible, setIsLayoutSetSelectorVisible] = useState(false); + const [showCreateSubform, setShowCreateSubform] = useState(false); + + function handleClick() { + setShowCreateSubform(true); + } if (isLayoutSetSelectorVisible) { return ( @@ -26,23 +36,34 @@ export const EditLayoutSet = ({ /> ); } - const layoutSetIsUndefined = !existingLayoutSetForSubform; if (layoutSetIsUndefined) { return ( - - - + <> + + + {t('ux_editor.component_properties.subform.create_layout_set_description')} + + + } + onClick={handleClick} + /> + + {showCreateSubform && } + ); } diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx index f4f80260adc..9be5df6e5d6 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx @@ -47,6 +47,29 @@ describe('EditLayoutSetForSubform', () => { expect(setLayoutSetButton).toBeInTheDocument(); }); + it('displays a button(Opprett et nytt skjema) to set a layout set for the subform', async () => { + const subformLayoutSetId = 'subformLayoutSetId'; + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + const createNewLayoutSetButton = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_layout_set_button'), + }); + expect(createNewLayoutSetButton).toBeInTheDocument(); + }); + + it('renders CreateNewLayoutSet component when clicking the create new layout set button', async () => { + const user = userEvent.setup(); + const subformLayoutSetId = 'subformLayoutSetId'; + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + const createNewLayoutSetButton = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_layout_set_button'), + }); + await user.click(createNewLayoutSetButton); + const createNewLayoutSetComponent = screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.subform.created_layout_set_name'), + }); + expect(createNewLayoutSetComponent).toBeInTheDocument(); + }); + it('displays a select to choose a layout set for the subform', async () => { const subformLayoutSetId = 'subformLayoutSetId'; renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); @@ -111,6 +134,26 @@ describe('EditLayoutSetForSubform', () => { ); }); + it('calls handleComponentChange after creating a new layout set and clicking Lukk button', async () => { + const user = userEvent.setup(); + const subformLayoutSetId = 'subformLayoutSetId'; + renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); + const createNewLayoutSetButton = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_layout_set_button'), + }); + await user.click(createNewLayoutSetButton); + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubForm'); + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + await user.click(saveButton); + expect(handleComponentChangeMock).toHaveBeenCalledTimes(1); + expect(handleComponentChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + layoutSet: 'NewSubForm', + }), + ); + }); + it('closes the view mode when clicking close button after selecting a layout set', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx index 80143db4bf2..db9441e69b3 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.tsx @@ -6,6 +6,7 @@ import { SubformUtilsImpl } from '../../../../classes/SubformUtils'; import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import type { IGenericEditComponent } from '../../../../components/config/componentConfig'; +import { useAppContext } from '../../../../hooks'; export const EditLayoutSetForSubform = ({ handleComponentChange, @@ -13,6 +14,7 @@ export const EditLayoutSetForSubform = ({ }: IGenericEditComponent): React.ReactElement => { const { org, app } = useStudioEnvironmentParams(); const { data: layoutSets } = useLayoutSetsQuery(org, app); + const { setSelectedFormLayoutSetName } = useAppContext(); const subformUtils = new SubformUtilsImpl(layoutSets.sets); @@ -25,10 +27,16 @@ export const EditLayoutSetForSubform = ({ handleComponentChange(updatedComponent); }; + function handleCreatedSubForm(layoutSetName: string) { + setSelectedFormLayoutSetName(layoutSetName); + handleUpdatedLayoutSet(layoutSetName); + } + return ( ); }; diff --git a/renovate.json b/renovate.json index 8398a0c6593..9eeb58b4793 100644 --- a/renovate.json +++ b/renovate.json @@ -20,6 +20,12 @@ } ], "ignorePaths": ["testdata/**", "src/**"], - "ignoreDeps": ["Moq", "Altinn.App.Core"], + "ignoreDeps": [ + "Moq", + "Altinn.App.Core", + "Microsoft.CodeAnalysis.CSharp", + "Microsoft.CodeAnalysis.Common", + "Basic.Reference.Assemblies" + ], "schedule": ["before 07:00 on Monday"] } diff --git a/yarn.lock b/yarn.lock index 8c089c4c25b..44a03132739 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4390,6 +4390,8 @@ __metadata: ts-jest: "npm:^29.1.1" typescript: "npm:5.6.2" uuid: "npm:10.0.0" + peerDependencies: + react-router-dom: ">=6.0.0" languageName: unknown linkType: soft