diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index d7814849c6..41674ccad0 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
- "version": "7.3.2",
+ "version": "9.0.2",
"commands": ["swagger"]
},
"dotnet-ef": {
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 54c31ee6ea..1d08145b5a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -376,62 +376,23 @@ jobs:
path: docker-stub-EU.zip
if-no-files-found: error
- - name: Build Public API Swagger
+ - name: Build Swagger files
run: |
- cd ./src/Api
- echo "Restore tools"
- dotnet tool restore
- echo "Publish"
- dotnet publish -c "Release" -o obj/build-output/publish
-
- dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \
- ./obj/build-output/publish/Api.dll public
- cd ../..
- env:
- ASPNETCORE_ENVIRONMENT: Production
- swaggerGen: "True"
- DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
- GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
+ cd ./dev
+ pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
- path: swagger.json
+ path: api.public.json
if-no-files-found: error
- - name: Build Internal API Swagger
- run: |
- cd ./src/Api
- echo "Restore API tools"
- dotnet tool restore
- echo "Publish API"
- dotnet publish -c "Release" -o obj/build-output/publish
-
- dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
- ./obj/build-output/publish/Api.dll internal
-
- cd ../Identity
-
- echo "Restore Identity tools"
- dotnet tool restore
- echo "Publish Identity"
- dotnet publish -c "Release" -o obj/build-output/publish
-
- dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
- ./obj/build-output/publish/Identity.dll v1
- cd ../..
- env:
- ASPNETCORE_ENVIRONMENT: Development
- swaggerGen: "True"
- DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
- GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
-
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
- path: internal.json
+ path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
diff --git a/.gitignore b/.gitignore
index e1b2153433..3b1f40e673 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,7 +129,7 @@ publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
-# TODO: Comment the next line if you want to checkin your web deploy settings
+# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
@@ -226,3 +226,8 @@ src/Notifications/Notifications.zip
bitwarden_license/src/Portal/Portal.zip
bitwarden_license/src/Sso/Sso.zip
**/src/**/flags.json
+
+# Generated swagger specs
+/identity.json
+/api.json
+/api.public.json
diff --git a/bitwarden-server.sln b/bitwarden-server.sln
index 2ec8d86e0e..dbc37372a1 100644
--- a/bitwarden-server.sln
+++ b/bitwarden-server.sln
@@ -133,6 +133,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -337,6 +339,10 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -391,6 +397,7 @@ Global
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
+ {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1
new file mode 100644
index 0000000000..02470a0b1d
--- /dev/null
+++ b/dev/generate_openapi_files.ps1
@@ -0,0 +1,19 @@
+Set-Location "$PSScriptRoot/.."
+
+$env:ASPNETCORE_ENVIRONMENT = "Development"
+$env:swaggerGen = "True"
+$env:DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX = "2"
+$env:GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING = "placeholder"
+
+dotnet tool restore
+
+# Identity
+Set-Location "./src/Identity"
+dotnet build
+dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1"
+
+# Api internal & public
+Set-Location "../../src/Api"
+dotnet build
+dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
+dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index 11af4d5e0a..d48f49626f 100644
--- a/src/Api/Api.csproj
+++ b/src/Api/Api.csproj
@@ -34,7 +34,7 @@
-
+
diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs
index 699fa3f804..450cb64bad 100644
--- a/src/Api/Startup.cs
+++ b/src/Api/Startup.cs
@@ -210,7 +210,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention());
});
- services.AddSwagger(globalSettings);
+ services.AddSwagger(globalSettings, Environment);
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
services.AddHostedService();
diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs
index 4f123d3f4f..aa2710c42a 100644
--- a/src/Api/Utilities/ServiceCollectionExtensions.cs
+++ b/src/Api/Utilities/ServiceCollectionExtensions.cs
@@ -19,7 +19,7 @@ namespace Bit.Api.Utilities;
public static class ServiceCollectionExtensions
{
- public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings)
+ public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
{
services.AddSwaggerGen(config =>
{
@@ -83,6 +83,14 @@ public static class ServiceCollectionExtensions
// config.UseReferencedDefinitionsForEnums();
config.SchemaFilter();
+ config.SchemaFilter();
+
+ // These two filters require debug symbols/git, so only add them in development mode
+ if (environment.IsDevelopment())
+ {
+ config.DocumentFilter();
+ config.OperationFilter();
+ }
var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml");
config.IncludeXmlComments(apiFilePath, true);
diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj
index 25327b17b7..18c627c5de 100644
--- a/src/Billing/Billing.csproj
+++ b/src/Billing/Billing.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs
index baaf9385af..ae628197e8 100644
--- a/src/Identity/Startup.cs
+++ b/src/Identity/Startup.cs
@@ -64,10 +64,19 @@ public class Startup
config.Filters.Add(new ModelStateValidationFilterAttribute());
});
- services.AddSwaggerGen(c =>
+ services.AddSwaggerGen(config =>
{
- c.SchemaFilter();
- c.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" });
+ config.SchemaFilter();
+ config.SchemaFilter();
+
+ // These two filters require debug symbols/git, so only add them in development mode
+ if (Environment.IsDevelopment())
+ {
+ config.DocumentFilter();
+ config.OperationFilter();
+ }
+
+ config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" });
});
if (!globalSettings.SelfHosted)
diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj
index 1951e4d509..445b98cce0 100644
--- a/src/SharedWeb/SharedWeb.csproj
+++ b/src/SharedWeb/SharedWeb.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs b/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs
new file mode 100644
index 0000000000..d26ae58e59
--- /dev/null
+++ b/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs
@@ -0,0 +1,40 @@
+#nullable enable
+
+using System.Text.Json;
+using Bit.Core.Utilities;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Bit.SharedWeb.Swagger;
+
+///
+/// Set the format of any strings that are decorated with the to "x-enc-string".
+/// This will allow the generated bindings to use a more appropriate type for encrypted strings.
+///
+public class EncryptedStringSchemaFilter : ISchemaFilter
+{
+ public void Apply(OpenApiSchema schema, SchemaFilterContext context)
+ {
+ if (context.Type == null || schema.Properties == null)
+ return;
+
+ foreach (var prop in context.Type.GetProperties())
+ {
+ // Only apply to string properties
+ if (prop.PropertyType != typeof(string))
+ continue;
+
+ // Check if the property has the EncryptedString attribute
+ if (prop.GetCustomAttributes(typeof(EncryptedStringAttribute), true).FirstOrDefault() != null)
+ {
+ // Convert prop.Name to camelCase for JSON schema property lookup
+ var jsonPropName = JsonNamingPolicy.CamelCase.ConvertName(prop.Name);
+
+ if (schema.Properties.TryGetValue(jsonPropName, out var value))
+ {
+ value.Format = "x-enc-string";
+ }
+ }
+ }
+ }
+}
diff --git a/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs b/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs
new file mode 100644
index 0000000000..86678722ce
--- /dev/null
+++ b/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs
@@ -0,0 +1,50 @@
+#nullable enable
+
+using System.Diagnostics;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Bit.SharedWeb.Swagger;
+
+///
+/// Add the Git commit that was used to generate the Swagger document, to help with debugging and reproducibility.
+///
+public class GitCommitDocumentFilter : IDocumentFilter
+{
+
+ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
+ {
+ if (!string.IsNullOrEmpty(GitCommit))
+ {
+ swaggerDoc.Extensions.Add("x-git-commit", new Microsoft.OpenApi.Any.OpenApiString(GitCommit));
+ }
+ }
+
+ public static string? GitCommit => _gitCommit.Value;
+
+ private static readonly Lazy _gitCommit = new(() =>
+ {
+ try
+ {
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "git",
+ Arguments = "rev-parse HEAD",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }
+ };
+ process.Start();
+ var result = process.StandardOutput.ReadLine()?.Trim();
+ process.WaitForExit();
+ return result ?? string.Empty;
+ }
+ catch
+ {
+ return null;
+ }
+ });
+}
diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs
new file mode 100644
index 0000000000..cbad1e9736
--- /dev/null
+++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs
@@ -0,0 +1,87 @@
+#nullable enable
+
+using System.Reflection;
+using System.Reflection.Metadata;
+using System.Reflection.Metadata.Ecma335;
+using System.Runtime.CompilerServices;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Bit.SharedWeb.Swagger;
+
+///
+/// Adds source file and line number information to the Swagger operation description.
+/// This can be useful for locating the source code of the operation in the repository,
+/// as the generated names are based on the HTTP path, and are hard to search for.
+///
+public class SourceFileLineOperationFilter : IOperationFilter
+{
+ private static readonly string _gitCommit = GitCommitDocumentFilter.GitCommit ?? "main";
+
+ public void Apply(OpenApiOperation operation, OperationFilterContext context)
+ {
+
+ var (fileName, lineNumber) = GetSourceFileLine(context.MethodInfo);
+ if (fileName != null && lineNumber > 0)
+ {
+ // Add the information with a link to the source file at the end of the operation description
+ operation.Description +=
+ $"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/{_gitCommit}/{fileName}#L{lineNumber}`]";
+
+ // Also add the information as extensions, so other tools can use it in the future
+ operation.Extensions.Add("x-source-file", new OpenApiString(fileName));
+ operation.Extensions.Add("x-source-line", new OpenApiInteger(lineNumber));
+ }
+ }
+
+ private static (string? fileName, int lineNumber) GetSourceFileLine(MethodInfo methodInfo)
+ {
+ // Get the location of the PDB file associated with the module of the method
+ var pdbPath = Path.ChangeExtension(methodInfo.Module.FullyQualifiedName, ".pdb");
+ if (!File.Exists(pdbPath)) return (null, 0);
+
+ // Open the PDB file and read the metadata
+ using var pdbStream = File.OpenRead(pdbPath);
+ using var metadataReaderProvider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
+ var metadataReader = metadataReaderProvider.GetMetadataReader();
+
+ // If the method is async, the compiler will generate a state machine,
+ // so we can't look for the original method, but we instead need to look
+ // for the MoveNext method of the state machine.
+ var attr = methodInfo.GetCustomAttribute();
+ if (attr?.StateMachineType != null)
+ {
+ var moveNext = attr.StateMachineType.GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+ if (moveNext != null) methodInfo = moveNext;
+ }
+
+ // Once we have the method, we can get its sequence points
+ var handle = (MethodDefinitionHandle)MetadataTokens.Handle(methodInfo.MetadataToken);
+ if (handle.IsNil) return (null, 0);
+ var sequencePoints = metadataReader.GetMethodDebugInformation(handle).GetSequencePoints();
+
+ // Iterate through the sequence points and pick the first one that has a valid line number
+ foreach (var sp in sequencePoints)
+ {
+ var docName = metadataReader.GetDocument(sp.Document).Name;
+ if (sp.StartLine != 0 && sp.StartLine != SequencePoint.HiddenLine && !docName.IsNil)
+ {
+ var fileName = metadataReader.GetString(docName);
+ var repoRoot = FindRepoRoot(AppContext.BaseDirectory);
+ var relativeFileName = repoRoot != null ? Path.GetRelativePath(repoRoot, fileName) : fileName;
+ return (relativeFileName, sp.StartLine);
+ }
+ }
+
+ return (null, 0);
+ }
+
+ private static string? FindRepoRoot(string startPath)
+ {
+ var dir = new DirectoryInfo(startPath);
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, ".git")))
+ dir = dir.Parent;
+ return dir?.FullName;
+ }
+}
diff --git a/test/SharedWeb.Test/EncryptedStringSchemaFilterTest.cs b/test/SharedWeb.Test/EncryptedStringSchemaFilterTest.cs
new file mode 100644
index 0000000000..172ddf5ee5
--- /dev/null
+++ b/test/SharedWeb.Test/EncryptedStringSchemaFilterTest.cs
@@ -0,0 +1,60 @@
+using Bit.Core.Utilities;
+using Bit.SharedWeb.Swagger;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+
+namespace SharedWeb.Test;
+
+public class EncryptedStringSchemaFilterTest
+{
+ private class TestClass
+ {
+ [EncryptedString]
+ public string SecretKey { get; set; }
+
+ public string Username { get; set; }
+
+ [EncryptedString]
+ public int Wrong { get; set; }
+ }
+
+ [Fact]
+ public void AnnotatedStringSetsFormat()
+ {
+ var schema = new OpenApiSchema
+ {
+ Properties = new Dictionary { { "secretKey", new() } }
+ };
+ var context = new SchemaFilterContext(typeof(TestClass), null, null, null);
+ var filter = new EncryptedStringSchemaFilter();
+ filter.Apply(schema, context);
+ Assert.Equal("x-enc-string", schema.Properties["secretKey"].Format);
+ }
+
+ [Fact]
+ public void NonAnnotatedStringIsIgnored()
+ {
+ var schema = new OpenApiSchema
+ {
+ Properties = new Dictionary { { "username", new() } }
+ };
+ var context = new SchemaFilterContext(typeof(TestClass), null, null, null);
+ var filter = new EncryptedStringSchemaFilter();
+ filter.Apply(schema, context);
+ Assert.Null(schema.Properties["username"].Format);
+ }
+
+ [Fact]
+ public void AnnotatedWrongTypeIsIgnored()
+ {
+ var schema = new OpenApiSchema
+ {
+ Properties = new Dictionary { { "wrong", new() } }
+ };
+ var context = new SchemaFilterContext(typeof(TestClass), null, null, null);
+ var filter = new EncryptedStringSchemaFilter();
+ filter.Apply(schema, context);
+ Assert.Null(schema.Properties["wrong"].Format);
+ }
+}
diff --git a/test/SharedWeb.Test/EnumSchemaFilterTest.cs b/test/SharedWeb.Test/EnumSchemaFilterTest.cs
new file mode 100644
index 0000000000..b0c14437c1
--- /dev/null
+++ b/test/SharedWeb.Test/EnumSchemaFilterTest.cs
@@ -0,0 +1,41 @@
+using Bit.SharedWeb.Swagger;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace SharedWeb.Test;
+
+public class EnumSchemaFilterTest
+{
+ private enum TestEnum
+ {
+ First,
+ Second,
+ Third
+ }
+
+ [Fact]
+ public void SetsEnumVarNamesExtension()
+ {
+ var schema = new OpenApiSchema();
+ var context = new SchemaFilterContext(typeof(TestEnum), null, null, null);
+ var filter = new EnumSchemaFilter();
+ filter.Apply(schema, context);
+
+ Assert.True(schema.Extensions.ContainsKey("x-enum-varnames"));
+ var extensions = schema.Extensions["x-enum-varnames"] as OpenApiArray;
+ Assert.NotNull(extensions);
+ Assert.Equal(["First", "Second", "Third"], extensions.Select(x => ((OpenApiString)x).Value));
+ }
+
+ [Fact]
+ public void DoesNotSetExtensionForNonEnum()
+ {
+ var schema = new OpenApiSchema();
+ var context = new SchemaFilterContext(typeof(string), null, null, null);
+ var filter = new EnumSchemaFilter();
+ filter.Apply(schema, context);
+
+ Assert.False(schema.Extensions.ContainsKey("x-enum-varnames"));
+ }
+}
diff --git a/test/SharedWeb.Test/GitCommitDocumentFilterTest.cs b/test/SharedWeb.Test/GitCommitDocumentFilterTest.cs
new file mode 100644
index 0000000000..542ef888f9
--- /dev/null
+++ b/test/SharedWeb.Test/GitCommitDocumentFilterTest.cs
@@ -0,0 +1,23 @@
+using Bit.SharedWeb.Swagger;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace SharedWeb.Test;
+
+public class GitCommitDocumentFilterTest
+{
+ [Fact]
+ public void AddsGitCommitExtensionIfAvailable()
+ {
+ var doc = new OpenApiDocument();
+ var context = new DocumentFilterContext(null, null, null);
+ var filter = new GitCommitDocumentFilter();
+ filter.Apply(doc, context);
+
+ Assert.True(doc.Extensions.ContainsKey("x-git-commit"));
+ var ext = doc.Extensions["x-git-commit"] as Microsoft.OpenApi.Any.OpenApiString;
+ Assert.NotNull(ext);
+ Assert.False(string.IsNullOrEmpty(ext.Value));
+
+ }
+}
diff --git a/test/SharedWeb.Test/GlobalUsings.cs b/test/SharedWeb.Test/GlobalUsings.cs
new file mode 100644
index 0000000000..9df1d42179
--- /dev/null
+++ b/test/SharedWeb.Test/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/test/SharedWeb.Test/SharedWeb.Test.csproj b/test/SharedWeb.Test/SharedWeb.Test.csproj
new file mode 100644
index 0000000000..8ae7a56a99
--- /dev/null
+++ b/test/SharedWeb.Test/SharedWeb.Test.csproj
@@ -0,0 +1,22 @@
+
+
+ false
+ SharedWeb.Test
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+
+
diff --git a/test/SharedWeb.Test/SourceFileLineOperationFilterTest.cs b/test/SharedWeb.Test/SourceFileLineOperationFilterTest.cs
new file mode 100644
index 0000000000..98da92c8c1
--- /dev/null
+++ b/test/SharedWeb.Test/SourceFileLineOperationFilterTest.cs
@@ -0,0 +1,33 @@
+using Bit.SharedWeb.Swagger;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace SharedWeb.Test;
+
+public class SourceFileLineOperationFilterTest
+{
+ private class DummyController
+ {
+ public void DummyMethod() { }
+ }
+
+ [Fact]
+ public void AddsSourceFileAndLineExtensionsIfAvailable()
+ {
+ var methodInfo = typeof(DummyController).GetMethod(nameof(DummyController.DummyMethod));
+ var operation = new OpenApiOperation();
+ var context = new OperationFilterContext(null, null, null, methodInfo);
+ var filter = new SourceFileLineOperationFilter();
+ filter.Apply(operation, context);
+
+ Assert.True(operation.Extensions.ContainsKey("x-source-file"));
+ Assert.True(operation.Extensions.ContainsKey("x-source-line"));
+ var fileExt = operation.Extensions["x-source-file"] as Microsoft.OpenApi.Any.OpenApiString;
+ var lineExt = operation.Extensions["x-source-line"] as Microsoft.OpenApi.Any.OpenApiInteger;
+ Assert.NotNull(fileExt);
+ Assert.NotNull(lineExt);
+
+ Assert.Equal(11, lineExt.Value);
+ Assert.StartsWith("test/SharedWeb.Test/", fileExt.Value);
+ }
+}