Initial commit, project structure and domain models
This commit is contained in:
431
.gitignore
vendored
Normal file
431
.gitignore
vendored
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
|
||||||
|
[Dd]ebug/x64/
|
||||||
|
[Dd]ebugPublic/x64/
|
||||||
|
[Rr]elease/x64/
|
||||||
|
[Rr]eleases/x64/
|
||||||
|
bin/x64/
|
||||||
|
obj/x64/
|
||||||
|
|
||||||
|
[Dd]ebug/x86/
|
||||||
|
[Dd]ebugPublic/x86/
|
||||||
|
[Rr]elease/x86/
|
||||||
|
[Rr]eleases/x86/
|
||||||
|
bin/x86/
|
||||||
|
obj/x86/
|
||||||
|
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
[Aa][Rr][Mm]64[Ee][Cc]/
|
||||||
|
bld/
|
||||||
|
[Oo]bj/
|
||||||
|
[Oo]ut/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Build results on 'Bin' directories
|
||||||
|
**/[Bb]in/*
|
||||||
|
# Uncomment if you have tasks that rely on *.refresh files to move binaries
|
||||||
|
# (https://github.com/github/gitignore/pull/3736)
|
||||||
|
#!**/[Bb]in/*.refresh
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
*.trx
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Approval Tests result files
|
||||||
|
*.received.*
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.idb
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: 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
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
**/.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
**/.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
**/.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
#tools/**
|
||||||
|
#!tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
MSBuild_Logs/
|
||||||
|
|
||||||
|
# AWS SAM Build and Temporary Artifacts folder
|
||||||
|
.aws-sam
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
**/.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
**/.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
**/.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains IDEs
|
||||||
|
.idea/
|
||||||
40
CLAUDE.md
Normal file
40
CLAUDE.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
|
||||||
|
|
||||||
|
## Planned Structure
|
||||||
|
|
||||||
|
- `src/admin/` — administrative components
|
||||||
|
- `tests/` — test suite
|
||||||
|
- `docs/` — documentation
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
|
||||||
|
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled
|
||||||
|
- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it)
|
||||||
|
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
|
||||||
|
- Any file modified should be evaluated for potential test cases and missing coverage areas.
|
||||||
|
|
||||||
|
# Coding
|
||||||
|
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
|
||||||
|
- Mock any external dependencies using Moq.
|
||||||
|
- Do not name any mocked objects with the work Mock in them
|
||||||
|
- Do not include any Arrange / Act / Assert comments in the code
|
||||||
|
|
||||||
|
|
||||||
|
## Claude
|
||||||
|
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
|
||||||
|
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end
|
||||||
|
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.
|
||||||
10
MicCheck.slnx
Normal file
10
MicCheck.slnx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
|
||||||
|
<Project Path="src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
|
||||||
|
<Project Path="tests/infrastructure/MicCheck.Infrastructure.Tests/MicCheck.Infrastructure.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: miccheck
|
||||||
|
POSTGRES_USER: miccheck
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U miccheck"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: src/api/MicCheck.Api/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Database=miccheck;Username=miccheck;Password=password
|
||||||
|
- Jwt__SecretKey=change-this-secret-key-in-production-must-be-32-chars
|
||||||
|
- Jwt__Issuer=MicCheck
|
||||||
|
- Jwt__Audience=MicCheck
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
104
docs/01_project_setup.md
Normal file
104
docs/01_project_setup.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Step 1: Project Setup & Solution Structure
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Establish the solution structure, project references, NuGet packages, and foundational infrastructure for the MicCheck C# port of Flagsmith.
|
||||||
|
|
||||||
|
## Solution Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mic-check.sln
|
||||||
|
├── src/
|
||||||
|
│ ├── MicCheck.Api/ # ASP.NET Core Web API — domain, services, and controllers
|
||||||
|
│ │ ├── Features/ # Feature flag domain, services, controllers, DTOs
|
||||||
|
│ │ ├── Environments/ # Environment domain, services, controllers, DTOs
|
||||||
|
│ │ ├── Projects/ # Project domain, services, controllers, DTOs
|
||||||
|
│ │ ├── Organizations/ # Organization domain, services, controllers, DTOs
|
||||||
|
│ │ ├── Segments/ # Segment domain, services, controllers, DTOs
|
||||||
|
│ │ ├── Identities/ # Identity domain, services, controllers, DTOs
|
||||||
|
│ │ ├── Audit/ # Audit log domain, service, controller, DTOs
|
||||||
|
│ │ ├── Webhooks/ # Webhook domain, service, controller, DTOs
|
||||||
|
│ │ ├── ApiKeys/ # API key domain, service, DTOs
|
||||||
|
│ │ └── Authentication/ # Auth handlers and policies
|
||||||
|
│ └── MicCheck.Infrastructure/ # EF Core DbContext, entity configurations, migrations
|
||||||
|
└── tests/
|
||||||
|
├── MicCheck.Api.Tests/
|
||||||
|
└── MicCheck.Infrastructure.Tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Each feature folder in `MicCheck.Api` contains everything for that area:
|
||||||
|
- Domain model(s) — e.g. `Feature.cs`, `FeatureState.cs`
|
||||||
|
- Service(s) — e.g. `FeatureService.cs`
|
||||||
|
- Controller(s) — e.g. `FeaturesController.cs`
|
||||||
|
- Request/Response DTOs — e.g. `CreateFeatureRequest.cs`, `FeatureResponse.cs`
|
||||||
|
|
||||||
|
## Project Configurations
|
||||||
|
|
||||||
|
### All `.csproj` files must include:
|
||||||
|
```xml
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
## MicCheck.Api
|
||||||
|
|
||||||
|
**Purpose:** The entire application — domain models, business logic services, and HTTP controllers all live here, organized by feature area.
|
||||||
|
|
||||||
|
**NuGet packages:**
|
||||||
|
- `Microsoft.AspNetCore.Authentication.JwtBearer` (latest)
|
||||||
|
- `Swashbuckle.AspNetCore` (latest)
|
||||||
|
- `Serilog.AspNetCore` (latest)
|
||||||
|
- `FluentValidation.AspNetCore` (latest)
|
||||||
|
|
||||||
|
**Project reference:**
|
||||||
|
- References `MicCheck.Infrastructure`
|
||||||
|
|
||||||
|
## MicCheck.Infrastructure
|
||||||
|
|
||||||
|
**Purpose:** EF Core DbContext, entity configurations, and PostgreSQL migrations only. No business logic.
|
||||||
|
|
||||||
|
**NuGet packages:**
|
||||||
|
- `Microsoft.EntityFrameworkCore` (latest for net10.0)
|
||||||
|
- `Npgsql.EntityFrameworkCore.PostgreSQL` (latest)
|
||||||
|
- `Microsoft.EntityFrameworkCore.Design` (latest)
|
||||||
|
|
||||||
|
**Key namespaces:**
|
||||||
|
- `MicCheck.Infrastructure.Data` — DbContext, entity configurations, migrations
|
||||||
|
|
||||||
|
## Test Projects
|
||||||
|
|
||||||
|
**NuGet packages (all test projects):**
|
||||||
|
- `NUnit` (latest)
|
||||||
|
- `NUnit3TestAdapter` (latest)
|
||||||
|
- `Moq` (latest)
|
||||||
|
- `Microsoft.NET.Test.Sdk` (latest)
|
||||||
|
- `coverlet.collector` (latest)
|
||||||
|
|
||||||
|
**Project references:**
|
||||||
|
- `MicCheck.Api.Tests` references `MicCheck.Api` and `MicCheck.Infrastructure`
|
||||||
|
- `MicCheck.Infrastructure.Tests` references `MicCheck.Infrastructure`
|
||||||
|
|
||||||
|
## Docker Compose (development)
|
||||||
|
|
||||||
|
Services required:
|
||||||
|
- `postgres:15-alpine` — primary database, port 5432
|
||||||
|
- `mic-check-api` — the ASP.NET Core API, port 8080
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql://miccheck:password@localhost:5432/miccheck
|
||||||
|
JWT_SECRET=<secret>
|
||||||
|
ADMIN_API_KEY=<key>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] Solution builds with `dotnet build` with zero warnings
|
||||||
|
- [ ] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
|
||||||
|
- [ ] Docker Compose brings up API and database cleanly
|
||||||
|
- [ ] `dotnet test` runs successfully (no tests yet, but harness works)
|
||||||
|
- [ ] Swagger UI is accessible at `/swagger`
|
||||||
67
docs/01_project_setup_output.md
Normal file
67
docs/01_project_setup_output.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Step 1 Implementation Output
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### Solution Structure
|
||||||
|
|
||||||
|
The solution (`MicCheck.slnx`) now contains four projects:
|
||||||
|
|
||||||
|
| Project | Path |
|
||||||
|
|---|---|
|
||||||
|
| `MicCheck.Api` | `src/api/MicCheck.Api/` |
|
||||||
|
| `MicCheck.Infrastructure` | `src/infrastructure/MicCheck.Infrastructure/` |
|
||||||
|
| `MicCheck.Api.Tests.Unit` | `tests/api/MicCheck.Api.Tests.Unit/` |
|
||||||
|
| `MicCheck.Infrastructure.Tests` | `tests/infrastructure/MicCheck.Infrastructure.Tests/` |
|
||||||
|
|
||||||
|
### Projects Created
|
||||||
|
|
||||||
|
**`MicCheck.Infrastructure`** — new class library targeting net10.0 with:
|
||||||
|
- `Microsoft.EntityFrameworkCore` 10.0.5
|
||||||
|
- `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1
|
||||||
|
- `Microsoft.EntityFrameworkCore.Design` 10.0.5
|
||||||
|
- Folder structure: `Data/Configurations/`, `Data/Migrations/`
|
||||||
|
|
||||||
|
**`MicCheck.Infrastructure.Tests`** — new NUnit test project targeting net10.0 with:
|
||||||
|
- `NUnit` 4.3.2, `NUnit3TestAdapter` 5.0.0, `NUnit.Analyzers` 4.7.0
|
||||||
|
- `Moq` 4.20.72, `coverlet.collector` 6.0.4
|
||||||
|
- Project reference to `MicCheck.Infrastructure`
|
||||||
|
|
||||||
|
### Projects Updated
|
||||||
|
|
||||||
|
**`MicCheck.Api`** — added:
|
||||||
|
- `TreatWarningsAsErrors`
|
||||||
|
- `Serilog.AspNetCore` 9.0.0
|
||||||
|
- `FluentValidation.AspNetCore` 11.3.0
|
||||||
|
- Project reference to `MicCheck.Infrastructure`
|
||||||
|
- `Program.cs` updated: Serilog bootstrap logger, `AddControllers()`, `AddFluentValidationAutoValidation()`, `AddRateLimiter("AdminApi")`, `MapControllers()`
|
||||||
|
- `appsettings.json` updated: Serilog config section, `ConnectionStrings:DefaultConnection`
|
||||||
|
|
||||||
|
**`MicCheck.Api.Tests.Unit`** — added:
|
||||||
|
- `TreatWarningsAsErrors`
|
||||||
|
- `coverlet.collector` 6.0.4
|
||||||
|
- Project reference to `MicCheck.Infrastructure`
|
||||||
|
|
||||||
|
### Feature Folders Created in MicCheck.Api
|
||||||
|
|
||||||
|
`Features/`, `Environments/`, `Projects/`, `Organizations/`, `Segments/`, `Identities/`, `Audit/`, `Webhooks/`, `ApiKeys/`, `Users/`
|
||||||
|
|
||||||
|
Existing `Auth/` folder retained (equivalent to `Authentication/` in the plan).
|
||||||
|
|
||||||
|
### Files Added
|
||||||
|
|
||||||
|
- `docker-compose.yml` — PostgreSQL 15-alpine + API service with health check
|
||||||
|
- `src/api/MicCheck.Api/Dockerfile` — multi-stage build (SDK → publish → runtime)
|
||||||
|
|
||||||
|
### Deviations from Plan
|
||||||
|
|
||||||
|
- **Scalar instead of Swashbuckle**: The project already used `Scalar.AspNetCore` + `Microsoft.AspNetCore.OpenApi`. This is the recommended approach for .NET 10 and was retained. The API reference UI is at `/scalar` rather than `/swagger`.
|
||||||
|
- **`Auth/` folder**: The existing auth code lives in `Auth/` rather than `Authentication/` as named in the plan. The existing code was not renamed.
|
||||||
|
- **NUnit template version**: `MicCheck.Infrastructure.Tests` was created with `dotnet new nunit` which brought in NUnit 4.3.2 / NUnit3TestAdapter 5.0.0 (slightly different patch versions from the Api test project). Both are current stable releases.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Solution builds with `dotnet build` with zero warnings
|
||||||
|
- [x] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
|
||||||
|
- [x] `dotnet test` runs successfully — 8 tests pass across 2 test projects
|
||||||
|
- [x] API reference UI accessible at `/scalar` (Scalar, not Swagger)
|
||||||
|
- [ ] Docker Compose brings up API and database cleanly — requires Docker; not verified in this step
|
||||||
332
docs/02_core_domain_models.md
Normal file
332
docs/02_core_domain_models.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# Step 2: Domain Models
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Define all domain entities in `MicCheck.Api`, organized by feature area. Each entity lives in the folder for the feature it belongs to.
|
||||||
|
|
||||||
|
## Constraints (from Flagsmith spec)
|
||||||
|
- Max 400 features per project
|
||||||
|
- Max 100 segments per project
|
||||||
|
- Max 100 segment overrides per environment
|
||||||
|
- Max 100 segment rule conditions
|
||||||
|
- Feature flag values: max 20,000 bytes
|
||||||
|
- Identity trait values: max 2,000 bytes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Organizations/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Organizations/Organization.cs
|
||||||
|
public class Organization
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<Project> Projects { get; init; } = [];
|
||||||
|
public ICollection<OrganizationUser> Members { get; init; } = [];
|
||||||
|
public ICollection<ApiKey> ApiKeys { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organizations/OrganizationUser.cs
|
||||||
|
public class OrganizationUser
|
||||||
|
{
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public int UserId { get; init; }
|
||||||
|
public OrganizationRole Role { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OrganizationRole { Admin, User }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Projects/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Projects/Project.cs
|
||||||
|
public class Project
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public bool HideDisabledFlags { get; set; }
|
||||||
|
public ICollection<Environment> Environments { get; init; } = [];
|
||||||
|
public ICollection<Feature> Features { get; init; } = [];
|
||||||
|
public ICollection<Segment> Segments { get; init; } = [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Environments/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Environments/Environment.cs
|
||||||
|
public class Environment
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string ApiKey { get; set; } // X-Environment-Key header value
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Features/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/Feature.cs
|
||||||
|
public class Feature
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public FeatureType Type { get; set; }
|
||||||
|
public string? InitialValue { get; set; } // max 20,000 bytes
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool DefaultEnabled { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||||
|
public ICollection<Tag> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FeatureType { Standard, MultiVariate }
|
||||||
|
|
||||||
|
// Features/FeatureState.cs
|
||||||
|
// Represents the value of a feature within a specific environment
|
||||||
|
public class FeatureState
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int FeatureId { get; init; }
|
||||||
|
public int EnvironmentId { get; init; }
|
||||||
|
public int? IdentityId { get; set; } // null = environment-level
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string? Value { get; set; } // max 20,000 bytes
|
||||||
|
public int? FeatureSegmentId { get; set; } // null = not a segment override
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
public int Version { get; set; } // optimistic concurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features/FeatureSegment.cs
|
||||||
|
// Links a feature to a segment with an override priority
|
||||||
|
public class FeatureSegment
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int FeatureId { get; init; }
|
||||||
|
public int SegmentId { get; init; }
|
||||||
|
public int EnvironmentId { get; init; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public FeatureState? FeatureState { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features/Tag.cs
|
||||||
|
public class Tag
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Label { get; set; }
|
||||||
|
public required string Color { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features/FeatureStateResult.cs
|
||||||
|
// Value object returned by FeatureEvaluationService — never exposed directly via API
|
||||||
|
public class FeatureStateResult
|
||||||
|
{
|
||||||
|
public required Feature Feature { get; init; }
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public string? Value { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Segments/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Segments/Segment.cs
|
||||||
|
public class Segment
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<SegmentRule> Rules { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments/SegmentRule.cs
|
||||||
|
public class SegmentRule
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int SegmentId { get; init; }
|
||||||
|
public int? ParentRuleId { get; set; } // allows nested AND/OR
|
||||||
|
public SegmentRuleType Type { get; set; }
|
||||||
|
public ICollection<SegmentCondition> Conditions { get; init; } = [];
|
||||||
|
public ICollection<SegmentRule> ChildRules { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SegmentRuleType { All, Any, None }
|
||||||
|
|
||||||
|
// Segments/SegmentCondition.cs
|
||||||
|
public class SegmentCondition
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int RuleId { get; init; }
|
||||||
|
public required string Property { get; set; } // trait key
|
||||||
|
public SegmentConditionOperator Operator { get; set; }
|
||||||
|
public required string Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SegmentConditionOperator
|
||||||
|
{
|
||||||
|
Equal, NotEqual,
|
||||||
|
GreaterThan, GreaterThanOrEqual,
|
||||||
|
LessThan, LessThanOrEqual,
|
||||||
|
Contains, NotContains,
|
||||||
|
Regex,
|
||||||
|
IsSet, IsNotSet,
|
||||||
|
In, NotIn,
|
||||||
|
PercentageSplit,
|
||||||
|
ModuloValueDivisorRemainder,
|
||||||
|
IsTrue, IsFalse
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Identities/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Identities/Identity.cs
|
||||||
|
// Represents an end-user or SDK client
|
||||||
|
public class Identity
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Identifier { get; set; }
|
||||||
|
public int EnvironmentId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<IdentityTrait> Traits { get; init; } = [];
|
||||||
|
public ICollection<FeatureState> FeatureStateOverrides { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identities/IdentityTrait.cs
|
||||||
|
public class IdentityTrait
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int IdentityId { get; init; }
|
||||||
|
public required string Key { get; set; }
|
||||||
|
public required string Value { get; set; } // max 2,000 bytes
|
||||||
|
public TraitValueType ValueType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TraitValueType { String, Integer, Float, Boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Audit/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Audit/AuditLog.cs
|
||||||
|
public class AuditLog
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string ResourceType { get; init; }
|
||||||
|
public required string ResourceId { get; init; }
|
||||||
|
public required string Action { get; init; } // Created, Updated, Deleted
|
||||||
|
public string? Changes { get; init; } // JSON diff
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public int? ProjectId { get; init; }
|
||||||
|
public int? EnvironmentId { get; init; }
|
||||||
|
public int? ActorUserId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Webhooks/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Webhooks/Webhook.cs
|
||||||
|
public class Webhook
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public string? Secret { get; set; }
|
||||||
|
public WebhookScope Scope { get; set; }
|
||||||
|
public int? EnvironmentId { get; set; }
|
||||||
|
public int? OrganizationId { get; set; }
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WebhookScope { Environment, Organization }
|
||||||
|
|
||||||
|
// Webhooks/WebhookDeliveryLog.cs
|
||||||
|
public class WebhookDeliveryLog
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int WebhookId { get; init; }
|
||||||
|
public required string EventType { get; init; }
|
||||||
|
public required string PayloadJson { get; init; }
|
||||||
|
public int? ResponseStatusCode { get; set; }
|
||||||
|
public string? ResponseBody { get; set; }
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTimeOffset AttemptedAt { get; init; }
|
||||||
|
public TimeSpan Duration { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/ApiKeys/
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ApiKeys/ApiKey.cs
|
||||||
|
public class ApiKey
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Key { get; set; } // hashed
|
||||||
|
public required string Prefix { get; set; } // first 8 chars for display
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MicCheck.Api/Users/ (referenced by auth)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Users/User.cs
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Email { get; set; }
|
||||||
|
public required string PasswordHash { get; set; }
|
||||||
|
public required string FirstName { get; set; }
|
||||||
|
public required string LastName { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public bool IsTwoFactorEnabled { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
|
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] All domain models compile within `MicCheck.Api` with no cross-project domain dependencies
|
||||||
|
- [ ] Nullable reference types enabled — all required properties explicitly marked
|
||||||
|
- [ ] No domain logic in DTOs — domain models and request/response types are distinct classes
|
||||||
|
- [ ] Unit tests validate domain constraints (e.g., feature count limits enforced in service logic)
|
||||||
72
docs/02_core_domain_models_output.md
Normal file
72
docs/02_core_domain_models_output.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Step 2 Implementation Output
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### Domain Models Created
|
||||||
|
|
||||||
|
19 files added to `MicCheck.Api`, each in its feature area folder:
|
||||||
|
|
||||||
|
| File | Namespace |
|
||||||
|
|---|---|
|
||||||
|
| `Organizations/Organization.cs` | `MicCheck.Api.Organizations` |
|
||||||
|
| `Organizations/OrganizationUser.cs` | `MicCheck.Api.Organizations` |
|
||||||
|
| `Projects/Project.cs` | `MicCheck.Api.Projects` |
|
||||||
|
| `Environments/Environment.cs` | `MicCheck.Api.Environments` |
|
||||||
|
| `Features/Feature.cs` | `MicCheck.Api.Features` |
|
||||||
|
| `Features/FeatureState.cs` | `MicCheck.Api.Features` |
|
||||||
|
| `Features/FeatureSegment.cs` | `MicCheck.Api.Features` |
|
||||||
|
| `Features/Tag.cs` | `MicCheck.Api.Features` |
|
||||||
|
| `Features/FeatureStateResult.cs` | `MicCheck.Api.Features` |
|
||||||
|
| `Segments/Segment.cs` | `MicCheck.Api.Segments` |
|
||||||
|
| `Segments/SegmentRule.cs` | `MicCheck.Api.Segments` |
|
||||||
|
| `Segments/SegmentCondition.cs` | `MicCheck.Api.Segments` |
|
||||||
|
| `Identities/Identity.cs` | `MicCheck.Api.Identities` |
|
||||||
|
| `Identities/IdentityTrait.cs` | `MicCheck.Api.Identities` |
|
||||||
|
| `Audit/AuditLog.cs` | `MicCheck.Api.Audit` |
|
||||||
|
| `Webhooks/Webhook.cs` | `MicCheck.Api.Webhooks` |
|
||||||
|
| `Webhooks/WebhookDeliveryLog.cs` | `MicCheck.Api.Webhooks` |
|
||||||
|
| `ApiKeys/ApiKey.cs` | `MicCheck.Api.ApiKeys` |
|
||||||
|
| `Users/User.cs` | `MicCheck.Api.Users` |
|
||||||
|
|
||||||
|
### Enums Defined
|
||||||
|
|
||||||
|
| Enum | Values | File |
|
||||||
|
|---|---|---|
|
||||||
|
| `OrganizationRole` | `User`, `Admin` | `OrganizationUser.cs` |
|
||||||
|
| `FeatureType` | `Standard`, `MultiVariate` | `Feature.cs` |
|
||||||
|
| `SegmentRuleType` | `All`, `Any`, `None` | `SegmentRule.cs` |
|
||||||
|
| `SegmentConditionOperator` | 17 values | `SegmentCondition.cs` |
|
||||||
|
| `TraitValueType` | `String`, `Integer`, `Float`, `Boolean` | `IdentityTrait.cs` |
|
||||||
|
| `WebhookScope` | `Environment`, `Organization` | `Webhook.cs` |
|
||||||
|
|
||||||
|
### Tests Created
|
||||||
|
|
||||||
|
10 test files covering all domain areas, 51 new tests (58 total in the suite):
|
||||||
|
|
||||||
|
| File | Tests |
|
||||||
|
|---|---|
|
||||||
|
| `Organizations/OrganizationTests.cs` | 6 |
|
||||||
|
| `Projects/ProjectTests.cs` | 3 |
|
||||||
|
| `Environments/EnvironmentTests.cs` | 2 |
|
||||||
|
| `Features/FeatureTests.cs` | 12 |
|
||||||
|
| `Segments/SegmentTests.cs` | 7 |
|
||||||
|
| `Identities/IdentityTests.cs` | 5 |
|
||||||
|
| `Audit/AuditLogTests.cs` | 2 |
|
||||||
|
| `Webhooks/WebhookTests.cs` | 6 |
|
||||||
|
| `ApiKeys/ApiKeyTests.cs` | 3 |
|
||||||
|
| `Users/UserTests.cs` | 4 |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
- **`OrganizationRole` enum ordering**: The plan declared `{ Admin, User }` making `Admin` the zero-value default. Reordered to `{ User, Admin }` so that an `OrganizationUser` created without an explicit role safely defaults to `User` rather than `Admin`.
|
||||||
|
|
||||||
|
- **`System.Environment` collision**: `MicCheck.Api.Environments.Environment` conflicts with `System.Environment` (pulled in via implicit usings). Resolved with `using AppEnvironment = MicCheck.Api.Environments.Environment;` in `Projects/Project.cs` and the corresponding test file. The class name `Environment` was kept as specified in the plan.
|
||||||
|
|
||||||
|
- **`using NUnit.Framework;`**: The existing `MicCheck.Api.Tests.Unit` project does not configure `NUnit.Framework` as a global using (unlike the `MicCheck.Infrastructure.Tests` template). All new test files include the using directive explicitly.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] All domain models compile with zero warnings
|
||||||
|
- [x] Nullable reference types enabled — all required properties explicitly marked with `required`
|
||||||
|
- [x] No domain logic in DTOs — models are plain C# classes with no service dependencies
|
||||||
|
- [x] `dotnet test` — 59 tests pass, 0 failures
|
||||||
195
docs/03_database_and_repositories.md
Normal file
195
docs/03_database_and_repositories.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Step 3: Database Schema & EF Core
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Implement the EF Core `DbContext` and entity configurations in `MicCheck.Infrastructure`, and wire up services in `MicCheck.Api` to use it directly. There is no repository abstraction layer — services inject `MicCheckDbContext` and query it with LINQ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DbContext
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Infrastructure/Data/MicCheckDbContext.cs
|
||||||
|
public class MicCheckDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<Organization> Organizations => Set<Organization>();
|
||||||
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
|
public DbSet<Environment> Environments => Set<Environment>();
|
||||||
|
public DbSet<Feature> Features => Set<Feature>();
|
||||||
|
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
|
||||||
|
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
|
||||||
|
public DbSet<Segment> Segments => Set<Segment>();
|
||||||
|
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
|
||||||
|
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
|
||||||
|
public DbSet<Identity> Identities => Set<Identity>();
|
||||||
|
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
|
||||||
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
public DbSet<Webhook> Webhooks => Set<Webhook>();
|
||||||
|
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
|
||||||
|
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||||
|
public DbSet<Tag> Tags => Set<Tag>();
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Configurations (EF Core Fluent API)
|
||||||
|
|
||||||
|
Each domain entity gets its own `IEntityTypeConfiguration<T>` in `MicCheck.Infrastructure/Data/Configurations/`:
|
||||||
|
|
||||||
|
### Key configurations:
|
||||||
|
|
||||||
|
**Feature:**
|
||||||
|
```csharp
|
||||||
|
entity.Property(f => f.Name).HasMaxLength(150).IsRequired();
|
||||||
|
entity.Property(f => f.InitialValue).HasMaxLength(20_000);
|
||||||
|
entity.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
|
||||||
|
```
|
||||||
|
|
||||||
|
**FeatureState:**
|
||||||
|
```csharp
|
||||||
|
entity.Property(fs => fs.Value).HasMaxLength(20_000);
|
||||||
|
entity.Property(fs => fs.Version).IsConcurrencyToken();
|
||||||
|
entity.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
|
||||||
|
```
|
||||||
|
|
||||||
|
**IdentityTrait:**
|
||||||
|
```csharp
|
||||||
|
entity.Property(t => t.Value).HasMaxLength(2_000);
|
||||||
|
entity.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
```csharp
|
||||||
|
entity.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
|
||||||
|
entity.HasIndex(e => e.ApiKey).IsUnique();
|
||||||
|
```
|
||||||
|
|
||||||
|
**ApiKey:**
|
||||||
|
```csharp
|
||||||
|
entity.Property(k => k.Key).HasMaxLength(512).IsRequired(); // stores hash
|
||||||
|
entity.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
|
||||||
|
entity.HasIndex(k => k.Key).IsUnique();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Usage Pattern
|
||||||
|
|
||||||
|
Services receive `MicCheckDbContext` via constructor injection and query it directly:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FeatureService.cs
|
||||||
|
public class FeatureService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
private readonly AuditService _auditService;
|
||||||
|
|
||||||
|
public FeatureService(MicCheckDbContext db, AuditService auditService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Feature> CreateAsync(
|
||||||
|
int projectId, string name, FeatureType type, string? initialValue,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var count = await _db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||||
|
if (count >= 400)
|
||||||
|
throw new DomainException("Projects may not have more than 400 features.");
|
||||||
|
|
||||||
|
var feature = new Feature { ProjectId = projectId, Name = name, Type = type, InitialValue = initialValue };
|
||||||
|
_db.Features.Add(feature);
|
||||||
|
|
||||||
|
// Auto-create FeatureState for every existing environment
|
||||||
|
var environments = await _db.Environments
|
||||||
|
.Where(e => e.ProjectId == projectId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var env in environments)
|
||||||
|
_db.FeatureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = env.Id });
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await _auditService.RecordAsync("Feature", feature.Id.ToString(), "Created", ...);
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Controllers call the service and map the returned domain object to a response DTO:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FeaturesController.cs
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<FeatureResponse>> Create(
|
||||||
|
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var feature = await _featureService.CreateAsync(
|
||||||
|
projectId, request.Name, request.Type, request.InitialValue, ct);
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
|
||||||
|
FeatureResponse.From(feature));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The static `From` method (or a mapping method on the controller) is the only place a domain object becomes a DTO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
- Use EF Core code-first migrations
|
||||||
|
- Migration naming: `{Timestamp}_{DescriptiveName}` (e.g., `20260101_InitialSchema`)
|
||||||
|
- All migrations in `MicCheck.Infrastructure/Data/Migrations/`
|
||||||
|
- Run via `dotnet ef migrations add` and `dotnet ef database update`
|
||||||
|
- The `--project MicCheck.Infrastructure --startup-project MicCheck.Api` flags are required since the DbContext is in Infrastructure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connection String Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
// appsettings.json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Support `DATABASE_URL` environment variable override (Heroku/Railway style parsing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed Data
|
||||||
|
|
||||||
|
Create an initial migration seeder for local development:
|
||||||
|
- Default organization: "Default"
|
||||||
|
- Default project: "My Project"
|
||||||
|
- Default environments: "Development", "Staging", "Production"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Indexes for Performance
|
||||||
|
|
||||||
|
Critical indexes beyond unique constraints:
|
||||||
|
- `FeatureState (EnvironmentId)` — for flag serving
|
||||||
|
- `FeatureState (IdentityId)` — for identity lookups
|
||||||
|
- `Identity (EnvironmentId, Identifier)` — unique, for identity resolution
|
||||||
|
- `AuditLog (OrganizationId, CreatedAt DESC)` — for audit queries
|
||||||
|
- `Webhook (EnvironmentId)` — for event dispatch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] `dotnet ef migrations add InitialSchema` generates a valid migration
|
||||||
|
- [ ] `dotnet ef database update` applies cleanly against a fresh PostgreSQL instance
|
||||||
|
- [ ] All unique constraints are enforced at the database level
|
||||||
|
- [ ] Services use `MicCheckDbContext` directly — no repository interfaces
|
||||||
|
- [ ] No raw SQL — all queries use EF Core LINQ
|
||||||
|
- [ ] Integration tests against a real test database verify EF configurations are correct
|
||||||
214
docs/04_authentication_and_authorization.md
Normal file
214
docs/04_authentication_and_authorization.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Step 4: Authentication & Authorization
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Implement the two authentication schemes Flagsmith uses — Environment Key (public, for Flags API) and API Key (secret, for Admin API) — plus JWT for dashboard users, and role-based authorization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Schemes
|
||||||
|
|
||||||
|
### 1. Environment Key (Flags API)
|
||||||
|
|
||||||
|
- Delivered via `X-Environment-Key` header
|
||||||
|
- Public — safe for client-side exposure
|
||||||
|
- Identifies which environment to serve flags for
|
||||||
|
- No user identity implied
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs
|
||||||
|
public class EnvironmentKeyAuthenticationHandler
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
// Reads X-Environment-Key header
|
||||||
|
// Looks up Environment from DB by ApiKey value
|
||||||
|
// Sets ClaimsPrincipal with EnvironmentId claim
|
||||||
|
// Scheme name: "EnvironmentKey"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Organisation API Key (Admin API)
|
||||||
|
|
||||||
|
- Delivered via `Authorization: Api-Key <TOKEN>` header
|
||||||
|
- Secret — never expose client-side
|
||||||
|
- Identifies the organization and grants admin access
|
||||||
|
- Stored hashed in DB; prefix shown for identification
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs
|
||||||
|
public class ApiKeyAuthenticationHandler
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
// Parses "Api-Key <TOKEN>" from Authorization header
|
||||||
|
// Hashes the provided token
|
||||||
|
// Looks up ApiKey record by hash
|
||||||
|
// Validates IsActive and ExpiresAt
|
||||||
|
// Sets ClaimsPrincipal with OrganizationId and UserId claims
|
||||||
|
// Scheme name: "ApiKey"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API key hashing:**
|
||||||
|
- Use `SHA256` for hashing stored keys
|
||||||
|
- Store prefix (first 8 chars) for display/identification only
|
||||||
|
- Generate new keys with `RandomNumberGenerator.GetBytes(32)` → Base64URL encoded
|
||||||
|
|
||||||
|
### 3. JWT (Dashboard Users)
|
||||||
|
|
||||||
|
- Standard bearer token authentication
|
||||||
|
- Used for the admin web UI
|
||||||
|
- Short-lived access token + refresh token pattern
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddAuthentication()
|
||||||
|
.AddJwtBearer("Bearer", options => {
|
||||||
|
options.TokenValidationParameters = new() {
|
||||||
|
ValidIssuer = config["Jwt:Issuer"],
|
||||||
|
ValidAudience = config["Jwt:Audience"],
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(
|
||||||
|
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authorization Policies
|
||||||
|
|
||||||
|
Define named policies in `MicCheck.Api/Authorization/`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Policies
|
||||||
|
public static class AuthorizationPolicies
|
||||||
|
{
|
||||||
|
public const string FlagsApiAccess = "FlagsApiAccess";
|
||||||
|
public const string AdminApiAccess = "AdminApiAccess";
|
||||||
|
public const string OrganizationAdmin = "OrganizationAdmin";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Policy registrations:**
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
|
||||||
|
policy.AddAuthenticationSchemes("EnvironmentKey")
|
||||||
|
.RequireClaim("EnvironmentId"));
|
||||||
|
|
||||||
|
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
|
||||||
|
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
|
||||||
|
.RequireAuthenticatedUser());
|
||||||
|
|
||||||
|
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
|
||||||
|
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
|
||||||
|
.RequireClaim("OrganizationRole", "Admin"));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
### Organization Roles
|
||||||
|
- `Admin` — full access to all projects and settings
|
||||||
|
- `User` — access to assigned projects only
|
||||||
|
|
||||||
|
### Project-Level Permissions
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Authorization/ProjectPermission.cs
|
||||||
|
public enum ProjectPermission
|
||||||
|
{
|
||||||
|
ViewProject,
|
||||||
|
CreateFeature, EditFeature, DeleteFeature,
|
||||||
|
CreateEnvironment, EditEnvironment, DeleteEnvironment,
|
||||||
|
CreateSegment, EditSegment, DeleteSegment,
|
||||||
|
ManageWebhooks,
|
||||||
|
ViewAuditLog
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicCheck.Api/Authorization/UserProjectPermission.cs
|
||||||
|
public class UserProjectPermission
|
||||||
|
{
|
||||||
|
public int UserId { get; init; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public ICollection<ProjectPermission> Permissions { get; init; } = [];
|
||||||
|
public bool IsAdmin { get; set; } // grants all permissions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization requirement handler:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs
|
||||||
|
// Checks UserProjectPermission for the current user and requested project
|
||||||
|
// Organization admins bypass all project-level checks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Model
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Users/User.cs
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Email { get; set; }
|
||||||
|
public required string PasswordHash { get; set; }
|
||||||
|
public required string FirstName { get; set; }
|
||||||
|
public required string LastName { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public bool IsTwoFactorEnabled { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
|
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Password hashing:** Use ASP.NET Core's `IPasswordHasher<User>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/login → Returns JWT access + refresh tokens
|
||||||
|
POST /api/v1/auth/refresh → Exchanges refresh token for new access token
|
||||||
|
POST /api/v1/auth/logout → Revokes refresh token
|
||||||
|
POST /api/v1/auth/register → Creates new user + organization
|
||||||
|
|
||||||
|
POST /api/v1/organisations/{id}/api-keys/ → Create new API key
|
||||||
|
GET /api/v1/organisations/{id}/api-keys/ → List API keys (prefix only)
|
||||||
|
DELETE /api/v1/organisations/{id}/api-keys/{keyId}/ → Revoke key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Apply to Admin API:
|
||||||
|
- 500 requests per minute per API key
|
||||||
|
- Use ASP.NET Core's built-in rate limiting middleware (`Microsoft.AspNetCore.RateLimiting`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
options.AddFixedWindowLimiter("AdminApi", limiter => {
|
||||||
|
limiter.PermitLimit = 500;
|
||||||
|
limiter.Window = TimeSpan.FromMinutes(1);
|
||||||
|
limiter.QueueLimit = 0;
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] `X-Environment-Key` header resolves to correct environment; invalid key returns 401
|
||||||
|
- [ ] `Authorization: Api-Key <TOKEN>` validates against hashed DB record; invalid key returns 401
|
||||||
|
- [ ] JWT tokens are issued and validated correctly
|
||||||
|
- [ ] Organization admins have access to all project operations
|
||||||
|
- [ ] Project-level permission checks block unauthorized users with 403
|
||||||
|
- [ ] Rate limit returns 429 after 500 Admin API requests within a minute
|
||||||
|
- [ ] Unit tests cover: key hashing, auth handler success/failure paths, permission checks
|
||||||
264
docs/05_flags_api.md
Normal file
264
docs/05_flags_api.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Step 5: Flags API (Public SDK Endpoint)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Implement the public Flags API — the endpoints consumed by SDK clients to retrieve feature flags and identify users. This is the high-traffic, read-heavy API authenticated via `X-Environment-Key`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
All routes prefixed with `/api/v1/` and authenticated with the `EnvironmentKey` scheme.
|
||||||
|
|
||||||
|
### GET /api/v1/flags/
|
||||||
|
Returns all enabled feature flags for the resolved environment.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"feature": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "dark_mode",
|
||||||
|
"type": "STANDARD"
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"feature_state_value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/identities/?identifier=user123
|
||||||
|
Returns flags for a specific user identity, including segment overrides and identity-level overrides.
|
||||||
|
|
||||||
|
### POST /api/v1/identities/
|
||||||
|
Identifies a user, optionally sets traits, and returns flags.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identifier": "user123",
|
||||||
|
"traits": [
|
||||||
|
{ "trait_key": "plan", "trait_value": "premium" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"traits": [...],
|
||||||
|
"flags": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/environment-document/
|
||||||
|
Returns the full environment configuration for **local evaluation mode**.
|
||||||
|
Used by server-side SDKs to pull all flags, segments, and targeting rules locally.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"api_key": "env-key-xxx",
|
||||||
|
"feature_states": [...],
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "My Project",
|
||||||
|
"segments": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Evaluation Service
|
||||||
|
|
||||||
|
Lives in `MicCheck.Api/Features/`. Injects `MicCheckDbContext` and returns domain objects — no DTOs.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FeatureEvaluationService.cs
|
||||||
|
public class FeatureEvaluationService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
private readonly SegmentEvaluator _segmentEvaluator;
|
||||||
|
private readonly FlagCache _flagCache;
|
||||||
|
|
||||||
|
// Resolves the effective FeatureState for a given environment
|
||||||
|
// Priority order (highest wins):
|
||||||
|
// 1. Identity-level override
|
||||||
|
// 2. Segment override (highest priority segment that matches)
|
||||||
|
// 3. Environment default
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
||||||
|
int environmentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
|
||||||
|
int environmentId,
|
||||||
|
string identifier,
|
||||||
|
IReadOnlyList<TraitInput>? traits = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity Resolution Service
|
||||||
|
|
||||||
|
Lives in `MicCheck.Api/Identities/`. Injects `MicCheckDbContext` and returns domain objects.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Identities/IdentityResolutionService.cs
|
||||||
|
public class IdentityResolutionService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
|
||||||
|
// 1. Look up Identity by (EnvironmentId, Identifier) — create if not found
|
||||||
|
// 2. Upsert provided traits by key
|
||||||
|
// 3. Return the resolved Identity with current traits loaded
|
||||||
|
public Task<Identity> ResolveAsync(
|
||||||
|
int environmentId,
|
||||||
|
string identifier,
|
||||||
|
IReadOnlyList<TraitInput>? traits,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment Evaluator
|
||||||
|
|
||||||
|
Lives in `MicCheck.Api/Segments/`. Pure logic — no DB access (data is preloaded by callers).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Segments/SegmentEvaluator.cs
|
||||||
|
public class SegmentEvaluator
|
||||||
|
{
|
||||||
|
public bool Evaluate(Segment segment, IReadOnlyList<IdentityTrait> traits);
|
||||||
|
|
||||||
|
private bool EvaluateRule(SegmentRule rule, IReadOnlyList<IdentityTrait> traits);
|
||||||
|
private bool EvaluateCondition(SegmentCondition condition, IReadOnlyList<IdentityTrait> traits);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Operator implementations** (pure functions):
|
||||||
|
- String: Equal, NotEqual, Contains, NotContains, Regex
|
||||||
|
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
|
||||||
|
- Boolean: IsTrue, IsFalse
|
||||||
|
- Membership: In, NotIn
|
||||||
|
- Existence: IsSet, IsNotSet
|
||||||
|
- Sampling: PercentageSplit (deterministic by identity+segment hash)
|
||||||
|
- Modulo: ModuloValueDivisorRemainder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request/Response DTOs
|
||||||
|
|
||||||
|
Live alongside their controllers in the relevant feature folder. DTOs are only constructed in controller action methods — services never return or accept DTOs.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/FlagResponse.cs
|
||||||
|
public record FlagResponse(
|
||||||
|
int Id,
|
||||||
|
FeatureSummaryResponse Feature,
|
||||||
|
bool Enabled,
|
||||||
|
string? FeatureStateValue
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static FlagResponse From(FeatureStateResult result) => new(
|
||||||
|
result.Feature.Id, // mapping happens here, in the response type, called from the controller
|
||||||
|
FeatureSummaryResponse.From(result.Feature),
|
||||||
|
result.Enabled,
|
||||||
|
result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features/FeatureSummaryResponse.cs
|
||||||
|
public record FeatureSummaryResponse(int Id, string Name, string Type)
|
||||||
|
{
|
||||||
|
public static FeatureSummaryResponse From(Feature feature) =>
|
||||||
|
new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identities/IdentityRequest.cs
|
||||||
|
public record IdentityRequest(
|
||||||
|
string Identifier,
|
||||||
|
IReadOnlyList<TraitInput>? Traits
|
||||||
|
);
|
||||||
|
|
||||||
|
// Identities/TraitInput.cs
|
||||||
|
public record TraitInput(
|
||||||
|
string TraitKey,
|
||||||
|
object? TraitValue // string | int | float | bool
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controllers
|
||||||
|
|
||||||
|
Controllers receive a request DTO, call a service with domain-level parameters, then map the domain result to a response DTO before returning.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FlagsController.cs
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/flags")]
|
||||||
|
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||||
|
public class FlagsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly FeatureEvaluationService _featureEvaluationService;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environmentId = int.Parse(User.FindFirstValue("EnvironmentId")!);
|
||||||
|
var results = await _featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
|
||||||
|
return Ok(results.Select(FlagResponse.From).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicCheck.Api/Identities/IdentitiesController.cs
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/identities")]
|
||||||
|
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||||
|
public class IdentitiesController : ControllerBase { ... }
|
||||||
|
|
||||||
|
// MicCheck.Api/Environments/EnvironmentDocumentController.cs
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/environment-document")]
|
||||||
|
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||||
|
public class EnvironmentDocumentController : ControllerBase { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
Lives in `MicCheck.Api/Features/`. Cache stores domain results, not serialized responses.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FlagCache.cs
|
||||||
|
public class FlagCache
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
|
public IReadOnlyList<FeatureStateResult>? Get(int environmentId);
|
||||||
|
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags);
|
||||||
|
public void Invalidate(int environmentId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache is invalidated inside `FeatureEvaluationService` whenever a `FeatureState` is modified. Designed to swap the backing store for Redis without changing the `FlagCache` interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] `GET /api/v1/flags/` returns correct flags for a valid environment key
|
||||||
|
- [ ] `GET /api/v1/flags/` returns 401 for missing or invalid environment key
|
||||||
|
- [ ] `POST /api/v1/identities/` creates identity + upserts traits + returns correct flags
|
||||||
|
- [ ] Segment override takes priority over environment default
|
||||||
|
- [ ] Identity override takes priority over segment override
|
||||||
|
- [ ] `GET /api/v1/environment-document/` returns full environment config
|
||||||
|
- [ ] Controllers never pass DTOs into services — only primitive/domain values
|
||||||
|
- [ ] `SegmentEvaluator` unit tests cover all operator types
|
||||||
|
- [ ] `FeatureEvaluationService` unit tests cover all three priority tiers
|
||||||
|
- [ ] `PercentageSplit` distributes identities deterministically and uniformly
|
||||||
325
docs/06_admin_api.md
Normal file
325
docs/06_admin_api.md
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
# Step 6: Admin API (Management Endpoints)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Implement the Admin API — the authenticated management API for creating and managing projects, environments, features, segments, users, and API keys. Authenticated via `Authorization: Api-Key <TOKEN>` or JWT bearer token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Versioning
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddApiVersioning(options =>
|
||||||
|
{
|
||||||
|
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||||
|
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||||
|
options.ReportApiVersions = true;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
- Current: `/api/v1/`
|
||||||
|
- Future: `/api/v2/`
|
||||||
|
|
||||||
|
All Admin API controllers:
|
||||||
|
```csharp
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||||
|
[EnableRateLimiting("AdminApi")]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint Groups
|
||||||
|
|
||||||
|
### Organizations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/organisations/
|
||||||
|
POST /api/v1/organisations/
|
||||||
|
GET /api/v1/organisations/{id}/
|
||||||
|
PUT /api/v1/organisations/{id}/
|
||||||
|
DELETE /api/v1/organisations/{id}/
|
||||||
|
GET /api/v1/organisations/{id}/users/
|
||||||
|
POST /api/v1/organisations/{id}/users/invite/
|
||||||
|
DELETE /api/v1/organisations/{id}/users/{userId}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/projects/
|
||||||
|
POST /api/v1/projects/
|
||||||
|
GET /api/v1/projects/{id}/
|
||||||
|
PUT /api/v1/projects/{id}/
|
||||||
|
DELETE /api/v1/projects/{id}/
|
||||||
|
GET /api/v1/projects/{id}/user-permissions/
|
||||||
|
POST /api/v1/projects/{id}/user-permissions/
|
||||||
|
PUT /api/v1/projects/{id}/user-permissions/{userId}/
|
||||||
|
DELETE /api/v1/projects/{id}/user-permissions/{userId}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environments
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/environments/
|
||||||
|
POST /api/v1/environments/
|
||||||
|
GET /api/v1/environments/{apiKey}/
|
||||||
|
PUT /api/v1/environments/{apiKey}/
|
||||||
|
DELETE /api/v1/environments/{apiKey}/
|
||||||
|
POST /api/v1/environments/{apiKey}/clone/
|
||||||
|
GET /api/v1/environments/{apiKey}/webhooks/
|
||||||
|
POST /api/v1/environments/{apiKey}/webhooks/
|
||||||
|
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||||
|
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/projects/{projectId}/features/
|
||||||
|
POST /api/v1/projects/{projectId}/features/
|
||||||
|
GET /api/v1/projects/{projectId}/features/{id}/
|
||||||
|
PUT /api/v1/projects/{projectId}/features/{id}/
|
||||||
|
PATCH /api/v1/projects/{projectId}/features/{id}/
|
||||||
|
DELETE /api/v1/projects/{projectId}/features/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature States (Environment-level)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/environments/{apiKey}/featurestates/
|
||||||
|
GET /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||||
|
PUT /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||||
|
PATCH /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Segments
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/projects/{projectId}/segments/
|
||||||
|
POST /api/v1/projects/{projectId}/segments/
|
||||||
|
GET /api/v1/projects/{projectId}/segments/{id}/
|
||||||
|
PUT /api/v1/projects/{projectId}/segments/{id}/
|
||||||
|
DELETE /api/v1/projects/{projectId}/segments/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identities (Admin view)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/environments/{apiKey}/identities/
|
||||||
|
GET /api/v1/environments/{apiKey}/identities/{id}/
|
||||||
|
DELETE /api/v1/environments/{apiKey}/identities/{id}/
|
||||||
|
GET /api/v1/environments/{apiKey}/identities/{id}/featurestates/
|
||||||
|
PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
|
||||||
|
DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Logs
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/organisations/{id}/audit-logs/
|
||||||
|
GET /api/v1/projects/{projectId}/audit-logs/
|
||||||
|
GET /api/v1/environments/{apiKey}/audit-logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/projects/{projectId}/tags/
|
||||||
|
POST /api/v1/projects/{projectId}/tags/
|
||||||
|
DELETE /api/v1/projects/{projectId}/tags/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Layer
|
||||||
|
|
||||||
|
Each feature area has a service class in its folder within `MicCheck.Api`. Services accept and return domain objects — they have no knowledge of request/response DTOs. Services inject `MicCheckDbContext` directly.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FeatureService.cs
|
||||||
|
public class FeatureService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
private readonly AuditService _auditService;
|
||||||
|
|
||||||
|
// Enforces: max 400 features per project
|
||||||
|
// On create: auto-creates FeatureState records for all existing environments
|
||||||
|
public Task<Feature> CreateAsync(int projectId, string name, FeatureType type,
|
||||||
|
string? initialValue, string? description, CancellationToken ct = default);
|
||||||
|
|
||||||
|
public Task<Feature> UpdateAsync(int id, string name, string? description,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
public Task DeleteAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
public Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicCheck.Api/Environments/EnvironmentService.cs
|
||||||
|
public class EnvironmentService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
private readonly AuditService _auditService;
|
||||||
|
|
||||||
|
// On create: auto-creates FeatureState records for all existing features
|
||||||
|
public Task<Environment> CreateAsync(int projectId, string name, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Clone: duplicates all environment-level FeatureStates; not identity/segment overrides
|
||||||
|
public Task<Environment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicCheck.Api/Segments/SegmentService.cs
|
||||||
|
public class SegmentService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
private readonly AuditService _auditService;
|
||||||
|
|
||||||
|
// Enforces: max 100 segments per project
|
||||||
|
// Enforces: max 100 segment rule conditions
|
||||||
|
public Task<Segment> CreateAsync(int projectId, string name,
|
||||||
|
IReadOnlyList<SegmentRuleDefinition> rules, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controller Pattern
|
||||||
|
|
||||||
|
Controllers are responsible for:
|
||||||
|
1. Receiving the request DTO
|
||||||
|
2. Validating it (FluentValidation runs automatically via middleware)
|
||||||
|
3. Calling the appropriate service with extracted domain-level values
|
||||||
|
4. Mapping the returned domain object(s) to a response DTO
|
||||||
|
5. Returning the HTTP response
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FeaturesController.cs
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/projects/{projectId}/features")]
|
||||||
|
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||||
|
[EnableRateLimiting("AdminApi")]
|
||||||
|
public class FeaturesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly FeatureService _featureService;
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<FeatureResponse>> Create(
|
||||||
|
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var feature = await _featureService.CreateAsync(
|
||||||
|
projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
|
||||||
|
FeatureResponse.From(feature));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var feature = await _featureService.FindByIdAsync(id, ct);
|
||||||
|
if (feature is null) return NotFound();
|
||||||
|
return Ok(FeatureResponse.From(feature));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Validation
|
||||||
|
|
||||||
|
Use FluentValidation for all incoming request DTOs. Validators live in the same folder as the DTO they validate.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/CreateFeatureRequest.cs
|
||||||
|
public record CreateFeatureRequest(
|
||||||
|
string Name,
|
||||||
|
FeatureType Type,
|
||||||
|
string? InitialValue,
|
||||||
|
string? Description
|
||||||
|
);
|
||||||
|
|
||||||
|
// MicCheck.Api/Features/CreateFeatureRequestValidator.cs
|
||||||
|
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
||||||
|
{
|
||||||
|
public CreateFeatureRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name)
|
||||||
|
.NotEmpty()
|
||||||
|
.MaximumLength(150)
|
||||||
|
.Matches("^[a-zA-Z0-9_-]+$");
|
||||||
|
|
||||||
|
RuleFor(x => x.InitialValue)
|
||||||
|
.MaximumLength(20_000)
|
||||||
|
.When(x => x.InitialValue is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response DTOs
|
||||||
|
|
||||||
|
Live in the same folder as their domain entity. The static `From` factory is the only mapping point.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Features/FeatureResponse.cs
|
||||||
|
public record FeatureResponse(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string Type,
|
||||||
|
string? InitialValue,
|
||||||
|
string? Description,
|
||||||
|
bool DefaultEnabled,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static FeatureResponse From(Feature feature) => new(
|
||||||
|
feature.Id, feature.Name,
|
||||||
|
feature.Type.ToString().ToUpperInvariant(),
|
||||||
|
feature.InitialValue, feature.Description,
|
||||||
|
feature.DefaultEnabled, feature.CreatedAt);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pagination wrapper for list endpoints:
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/PaginatedResponse.cs
|
||||||
|
public record PaginatedResponse<T>(
|
||||||
|
int Count,
|
||||||
|
string? Next,
|
||||||
|
string? Previous,
|
||||||
|
IReadOnlyList<T> Results
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Cloning
|
||||||
|
|
||||||
|
When cloning an environment:
|
||||||
|
1. Create new `Environment` record with a fresh `ApiKey`
|
||||||
|
2. Copy all environment-level `FeatureState` records from the source environment
|
||||||
|
3. Do NOT copy identity-level overrides
|
||||||
|
4. Do NOT copy segment overrides (those follow segment definitions)
|
||||||
|
5. Emit an audit log entry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] Full CRUD for organizations, projects, environments, features, segments
|
||||||
|
- [ ] Creating a feature auto-creates `FeatureState` for all project environments
|
||||||
|
- [ ] Creating an environment auto-creates `FeatureState` for all project features
|
||||||
|
- [ ] 400 feature limit enforced (returns 400 with descriptive error)
|
||||||
|
- [ ] 100 segment limit enforced
|
||||||
|
- [ ] 100 segment condition limit enforced
|
||||||
|
- [ ] FluentValidation errors return 422 with field-level detail
|
||||||
|
- [ ] Environment clone creates independent copy of feature states
|
||||||
|
- [ ] All endpoints return 401 for missing credentials, 403 for insufficient permissions
|
||||||
|
- [ ] Paginated list endpoints default to page size 20, max 100
|
||||||
|
- [ ] Controllers never call `_db` directly — all data access goes through a service
|
||||||
|
- [ ] Services never reference request/response DTO types
|
||||||
|
- [ ] Unit tests for all service methods covering success paths and constraint violations
|
||||||
249
docs/07_audit_and_webhooks.md
Normal file
249
docs/07_audit_and_webhooks.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Step 7: Audit Logging & Webhooks
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Implement the audit trail system (recording all changes to flags, environments, segments, etc.) and the webhook system (notifying external systems of flag state changes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Logging
|
||||||
|
|
||||||
|
### What Gets Audited
|
||||||
|
|
||||||
|
Every create, update, and delete operation on:
|
||||||
|
- Features and FeatureStates
|
||||||
|
- Environments
|
||||||
|
- Segments
|
||||||
|
- Identities (admin operations)
|
||||||
|
- API Keys (create/revoke)
|
||||||
|
- User permissions
|
||||||
|
- Organization settings
|
||||||
|
- Project settings
|
||||||
|
|
||||||
|
### AuditLog Domain Model
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Audit/AuditLog.cs (already defined in Step 2)
|
||||||
|
// Key fields:
|
||||||
|
// ResourceType: "Feature", "FeatureState", "Environment", etc.
|
||||||
|
// ResourceId: string representation of the entity's PK
|
||||||
|
// Action: "Created", "Updated", "Deleted"
|
||||||
|
// Changes: JSON diff of before/after values (for Updates)
|
||||||
|
// ActorUserId: null for API key operations without user context
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Service
|
||||||
|
|
||||||
|
Lives in `MicCheck.Api/Audit/`. Injects `MicCheckDbContext` directly.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Audit/AuditService.cs
|
||||||
|
public class AuditService
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
|
||||||
|
public Task RecordAsync(
|
||||||
|
string resourceType,
|
||||||
|
string resourceId,
|
||||||
|
string action,
|
||||||
|
int organizationId,
|
||||||
|
int? projectId = null,
|
||||||
|
int? environmentId = null,
|
||||||
|
int? actorUserId = null,
|
||||||
|
object? before = null,
|
||||||
|
object? after = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change diffing:**
|
||||||
|
- Serialize `before` and `after` objects to JSON
|
||||||
|
- Store both in `Changes` as `{ "before": {...}, "after": {...} }`
|
||||||
|
- Use `System.Text.Json` for serialization
|
||||||
|
|
||||||
|
### Integration with Services
|
||||||
|
|
||||||
|
Audit logging is called directly from other service methods:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In FeatureService.CreateAsync:
|
||||||
|
await _auditService.RecordAsync(
|
||||||
|
resourceType: "Feature",
|
||||||
|
resourceId: feature.Id.ToString(),
|
||||||
|
action: "Created",
|
||||||
|
organizationId: project.OrganizationId,
|
||||||
|
projectId: feature.ProjectId,
|
||||||
|
after: feature);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Log Queries
|
||||||
|
|
||||||
|
`AuditService` exposes a query method returning domain objects; the controller maps to response DTOs.
|
||||||
|
|
||||||
|
Support filtering by:
|
||||||
|
- Date range (`from`, `to`)
|
||||||
|
- Resource type
|
||||||
|
- Action type
|
||||||
|
- Project
|
||||||
|
- Environment
|
||||||
|
- Actor user
|
||||||
|
|
||||||
|
Always ordered by `CreatedAt DESC`. Paginated (default 20, max 100).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Audit/AuditController.cs
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> List(
|
||||||
|
int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (logs, total) = await _auditService.ListAsync(id, filter, ct);
|
||||||
|
return Ok(new PaginatedResponse<AuditLogResponse>(
|
||||||
|
total, null, null,
|
||||||
|
logs.Select(AuditLogResponse.From).ToList()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
### Webhook Triggers
|
||||||
|
|
||||||
|
Webhooks fire on:
|
||||||
|
- `FLAG_UPDATED` — any `FeatureState` change
|
||||||
|
- `FLAG_DELETED` — feature deleted
|
||||||
|
- `AUDIT_LOG_CREATED` — any audit log entry (organization webhooks)
|
||||||
|
|
||||||
|
### Webhook Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"changed_by": "user@example.com",
|
||||||
|
"timestamp": "2026-04-06T12:00:00Z",
|
||||||
|
"new_state": {
|
||||||
|
"id": 42,
|
||||||
|
"enabled": true,
|
||||||
|
"feature_state_value": null,
|
||||||
|
"feature": { "id": 1, "name": "dark_mode" },
|
||||||
|
"environment": { "id": 5, "name": "Production" }
|
||||||
|
},
|
||||||
|
"previous_state": { ... }
|
||||||
|
},
|
||||||
|
"event_type": "FLAG_UPDATED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Event & Dispatcher
|
||||||
|
|
||||||
|
Live in `MicCheck.Api/Webhooks/`. Dispatcher injects `MicCheckDbContext` directly.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Webhooks/WebhookEvent.cs
|
||||||
|
public class WebhookEvent
|
||||||
|
{
|
||||||
|
public required string EventType { get; init; }
|
||||||
|
public required object Data { get; init; }
|
||||||
|
public int? EnvironmentId { get; init; }
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MicCheck.Api/Webhooks/WebhookDispatcher.cs
|
||||||
|
public class WebhookDispatcher
|
||||||
|
{
|
||||||
|
private readonly MicCheckDbContext _db;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
// Finds all active webhooks for the environment (and org-level)
|
||||||
|
// Serializes payload to JSON
|
||||||
|
// Signs payload with HMAC-SHA256 using webhook secret
|
||||||
|
// POSTs to webhook URL with timeout of 10 seconds
|
||||||
|
// Stores delivery result in WebhookDeliveryLog
|
||||||
|
public Task DispatchAsync(WebhookEvent webhookEvent, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Signing
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// HMAC-SHA256 signature in header:
|
||||||
|
// X-Flagsmith-Signature: sha256=<hex_digest>
|
||||||
|
|
||||||
|
private static string ComputeSignature(string secret, string payload)
|
||||||
|
{
|
||||||
|
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||||
|
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||||
|
var hashBytes = HMACSHA256.HashData(keyBytes, payloadBytes);
|
||||||
|
return "sha256=" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Queue (Async Dispatch)
|
||||||
|
|
||||||
|
Lives in `MicCheck.Api/Webhooks/`. Uses `System.Threading.Channels` so dispatch does not block the API response.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Api/Webhooks/WebhookQueue.cs
|
||||||
|
public class WebhookQueue
|
||||||
|
{
|
||||||
|
private readonly Channel<WebhookEvent> _channel = Channel.CreateUnbounded<WebhookEvent>();
|
||||||
|
|
||||||
|
public ValueTask EnqueueAsync(WebhookEvent webhookEvent) =>
|
||||||
|
_channel.Writer.WriteAsync(webhookEvent);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WebhookEvent> ReadAllAsync(CancellationToken ct) =>
|
||||||
|
_channel.Reader.ReadAllAsync(ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Background Service
|
||||||
|
|
||||||
|
Lives in `MicCheck.Infrastructure/Webhooks/`. Polls `WebhookDeliveryLog` and retries failures.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// MicCheck.Infrastructure/Webhooks/WebhookRetryBackgroundService.cs
|
||||||
|
public class WebhookRetryBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
// Polls WebhookDeliveryLog for failed deliveries within retry window
|
||||||
|
// Up to 3 attempts: immediate, +5 min, +30 min
|
||||||
|
// Reattempts via WebhookDispatcher
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Strategy
|
||||||
|
|
||||||
|
- Up to 3 delivery attempts (immediate, +5 min, +30 min)
|
||||||
|
- Failed deliveries are logged but do not block the API response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Admin Endpoints
|
||||||
|
|
||||||
|
Controllers map domain objects to/from DTOs in the action method, calling `WebhookService` for all data access.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/environments/{apiKey}/webhooks/
|
||||||
|
POST /api/v1/environments/{apiKey}/webhooks/
|
||||||
|
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||||
|
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||||
|
GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries/
|
||||||
|
|
||||||
|
GET /api/v1/organisations/{id}/webhooks/
|
||||||
|
POST /api/v1/organisations/{id}/webhooks/
|
||||||
|
PUT /api/v1/organisations/{id}/webhooks/{id}/
|
||||||
|
DELETE /api/v1/organisations/{id}/webhooks/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] Every create/update/delete in the Admin API produces an `AuditLog` record
|
||||||
|
- [ ] Audit logs correctly capture before/after state for updates
|
||||||
|
- [ ] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results
|
||||||
|
- [ ] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs in the action
|
||||||
|
- [ ] Webhooks fire within 1 second of a flag state change
|
||||||
|
- [ ] HMAC-SHA256 signature is correct and verifiable by receivers
|
||||||
|
- [ ] Failed webhook deliveries are logged with status code and response body
|
||||||
|
- [ ] Retry logic attempts delivery 3 times with backoff
|
||||||
|
- [ ] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`)
|
||||||
|
- [ ] Unit tests cover: signature computation, payload serialization, retry scheduling
|
||||||
|
- [ ] Unit tests cover: audit diff generation for create/update/delete scenarios
|
||||||
277
docs/08_testing_strategy.md
Normal file
277
docs/08_testing_strategy.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# Step 8: Testing Strategy
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Establish a comprehensive testing strategy that achieves high coverage of business logic, enforces BDD-style test naming, and validates the full request pipeline without touching external resources (database, file system, network).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── MicCheck.Core.Tests/
|
||||||
|
│ ├── Features/
|
||||||
|
│ │ ├── FeatureEvaluationServiceTests.cs
|
||||||
|
│ │ └── FeatureServiceTests.cs
|
||||||
|
│ ├── Segments/
|
||||||
|
│ │ └── SegmentEvaluatorTests.cs
|
||||||
|
│ ├── Environments/
|
||||||
|
│ │ └── EnvironmentServiceTests.cs
|
||||||
|
│ ├── Identities/
|
||||||
|
│ │ └── IdentityResolutionServiceTests.cs
|
||||||
|
│ ├── Audit/
|
||||||
|
│ │ └── AuditServiceTests.cs
|
||||||
|
│ └── Webhooks/
|
||||||
|
│ ├── WebhookDispatcherTests.cs
|
||||||
|
│ └── WebhookSignatureTests.cs
|
||||||
|
├── MicCheck.Api.Tests/
|
||||||
|
│ ├── Flags/
|
||||||
|
│ │ ├── FlagsControllerTests.cs
|
||||||
|
│ │ └── IdentitiesControllerTests.cs
|
||||||
|
│ ├── Admin/
|
||||||
|
│ │ ├── FeaturesControllerTests.cs
|
||||||
|
│ │ ├── EnvironmentsControllerTests.cs
|
||||||
|
│ │ ├── SegmentsControllerTests.cs
|
||||||
|
│ │ └── OrganizationsControllerTests.cs
|
||||||
|
│ └── Authentication/
|
||||||
|
│ ├── EnvironmentKeyAuthenticationHandlerTests.cs
|
||||||
|
│ └── ApiKeyAuthenticationHandlerTests.cs
|
||||||
|
└── MicCheck.Infrastructure.Tests/
|
||||||
|
└── (integration tests — marked [Category("Integration")])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Convention (BDD Style)
|
||||||
|
|
||||||
|
```
|
||||||
|
WhenA{Subject}_{Condition}_Then{ExpectedOutcome}
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
|
||||||
|
WhenAnIdentityIsResolved_WithMatchingSegment_ThenSegmentFlagOverrideIsReturned
|
||||||
|
WhenAWebhookIsDispatched_WithASecret_ThenPayloadIsSignedWithHmacSha256
|
||||||
|
WhenAnEnvironmentKeyIsInvalid_ThenAuthenticationFails
|
||||||
|
WhenAFlagIsUpdated_ThenAnAuditLogEntryIsCreated
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### Unit Tests (Api — Services)
|
||||||
|
|
||||||
|
Services depend on `MicCheckDbContext`. Use an in-memory SQLite EF Core provider (or a real test-scoped PostgreSQL instance for integration tests) rather than mocking the DbContext. Mock only non-EF dependencies such as `AuditService` or `WebhookQueue`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class FeatureServiceTests
|
||||||
|
{
|
||||||
|
private MicCheckDbContext _db = null!;
|
||||||
|
private Mock<AuditService> _auditService = null!;
|
||||||
|
private FeatureService _sut = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
_db = new MicCheckDbContext(options);
|
||||||
|
_auditService = new Mock<AuditService>();
|
||||||
|
_sut = new FeatureService(_db, _auditService.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown() => _db.Dispose();
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown()
|
||||||
|
{
|
||||||
|
var project = new Project { Name = "Test", OrganizationId = 1 };
|
||||||
|
_db.Projects.Add(project);
|
||||||
|
for (var i = 0; i < 400; i++)
|
||||||
|
_db.Features.Add(new Feature { Name = $"flag_{i}", ProjectId = project.Id });
|
||||||
|
_db.SaveChanges();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
async () => await _sut.CreateAsync(project.Id, "new_flag", FeatureType.Standard, null, null),
|
||||||
|
Throws.TypeOf<DomainException>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Controller Tests
|
||||||
|
|
||||||
|
Use `WebApplicationFactory<Program>` with mocked services for HTTP-level tests:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class FlagsControllerTests
|
||||||
|
{
|
||||||
|
private WebApplicationFactory<Program> _factory = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<IFeatureEvaluationService>(
|
||||||
|
Mock.Of<IFeatureEvaluationService>());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown() => _factory.Dispose();
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenGetFlagsIsCalledWithAValidEnvironmentKey_ThenFlagsAreReturned()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add("X-Environment-Key", "valid-env-key");
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/v1/flags/");
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SegmentEvaluator Test Coverage
|
||||||
|
|
||||||
|
The `SegmentEvaluator` must have exhaustive unit tests for every operator:
|
||||||
|
|
||||||
|
The `SegmentEvaluator` takes no dependencies, so it is constructed directly with no mocking needed.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class SegmentEvaluatorTests
|
||||||
|
{
|
||||||
|
private readonly SegmentEvaluator _sut = new();
|
||||||
|
|
||||||
|
[TestCase("premium", "premium", true)]
|
||||||
|
[TestCase("premium", "free", false)]
|
||||||
|
public void WhenEvaluatingEqualOperator_ThenResultMatchesExpectation(
|
||||||
|
string traitValue, string conditionValue, bool expected) { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenEvaluatingPercentageSplit_WithSameIdentifier_ThenResultIsDeterministic() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenEvaluatingPercentageSplit_WithZeroPercent_ThenNobodyMatches() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenEvaluatingPercentageSplit_WithHundredPercent_ThenEveryoneMatches() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenASegmentHasNestedRules_ThenAllRulesAreEvaluated() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Evaluation Priority Tests
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class FeatureEvaluationServiceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task WhenIdentityHasAnOverride_ThenIdentityOverrideTakesPriorityOverSegment() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenIdentityMatchesSegment_ThenSegmentOverrideTakesPriorityOverEnvironmentDefault() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenIdentityMatchesMultipleSegments_ThenHighestPrioritySegmentWins() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenIdentityMatchesNoSegment_ThenEnvironmentDefaultIsReturned() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Tests
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[TestFixture]
|
||||||
|
public class EnvironmentKeyAuthenticationHandlerTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task WhenEnvironmentKeyHeaderIsMissing_ThenAuthenticationFails() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenEnvironmentKeyIsValid_ThenAuthenticationSucceedsWithEnvironmentIdClaim() { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ApiKeyAuthenticationHandlerTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task WhenApiKeyIsRevoked_ThenAuthenticationFails() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenApiKeyIsExpired_ThenAuthenticationFails() { ... }
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationIdClaim() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain Constraint Tests
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// For each enforced constraint, there must be a test:
|
||||||
|
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
|
||||||
|
WhenASegmentIsCreated_AndProjectHasReachedMaxSegments_ThenAnExceptionIsThrown
|
||||||
|
WhenASegmentConditionIsAdded_AndRuleHasReachedMaxConditions_ThenAnExceptionIsThrown
|
||||||
|
WhenAFeatureStateValueExceedsMaxBytes_ThenValidationFails
|
||||||
|
WhenATraitValueExceedsMaxBytes_ThenValidationFails
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Coverage Target
|
||||||
|
|
||||||
|
- `MicCheck.Api` services and domain logic: 90%+ line coverage
|
||||||
|
- `MicCheck.Api` controllers and auth handlers: 80%+ line coverage
|
||||||
|
- All new code must include corresponding tests per CLAUDE.md requirements
|
||||||
|
|
||||||
|
Run coverage via:
|
||||||
|
```bash
|
||||||
|
dotnet test --collect:"XPlat Code Coverage"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI Test Execution
|
||||||
|
|
||||||
|
Tests are separated by NUnit category:
|
||||||
|
```bash
|
||||||
|
# Run all unit tests (fast, no external dependencies):
|
||||||
|
dotnet test --filter "Category!=Integration"
|
||||||
|
|
||||||
|
# Run integration tests (requires database):
|
||||||
|
dotnet test --filter "Category=Integration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ] All tests use BDD-style names (`WhenA{Subject}_{Condition}_Then{Outcome}`)
|
||||||
|
- [ ] No mocked objects named with the word "Mock" (use descriptive names or `_sut`)
|
||||||
|
- [ ] No Arrange/Act/Assert comments in test code
|
||||||
|
- [ ] `SegmentEvaluator` has tests for every `SegmentConditionOperator` value
|
||||||
|
- [ ] Feature evaluation priority (identity > segment > environment) is tested explicitly
|
||||||
|
- [ ] All domain constraint limits have corresponding tests
|
||||||
|
- [ ] `dotnet test` passes cleanly with zero failures
|
||||||
|
- [ ] Core project achieves 90%+ line coverage
|
||||||
71
docs/admin/00-New-Website-output.md
Normal file
71
docs/admin/00-New-Website-output.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# MicCheck Admin SPA — Implementation Output
|
||||||
|
|
||||||
|
All source files have been created under `src/admin/`. Node.js/npm is not installed on this machine, so the build and tests could not be run. See setup instructions below.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Node.js (via nvm is recommended)
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
source ~/.bashrc
|
||||||
|
nvm install --lts
|
||||||
|
|
||||||
|
# Then from the admin directory:
|
||||||
|
cd /home/james/src/mic-check/src/admin
|
||||||
|
npm install
|
||||||
|
npm run build # produces dist/
|
||||||
|
npm test # runs 6 Jest tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install via your package manager:
|
||||||
|
```bash
|
||||||
|
sudo apt install nodejs npm # Ubuntu/Debian
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### Config files (`src/admin/`)
|
||||||
|
- `package.json` — scripts: `build`, `serve`, `test`; all deps pinned
|
||||||
|
- `webpack.config.js` — vue-loader, ts-loader, sass-loader, VuetifyPlugin, HtmlWebpackPlugin
|
||||||
|
- `tsconfig.json`, `jest.config.js`, `babel.config.js`
|
||||||
|
|
||||||
|
### Application (`src/admin/src/`)
|
||||||
|
- `main.ts` / `App.vue` — entry point
|
||||||
|
- `plugins/vuetify.ts` — Vuetify 3 with MDI icons + light theme
|
||||||
|
- `router/index.ts` — routes for `/`, `/features`, `/environments`, `/projects`, `/profile`, `/settings`
|
||||||
|
- `assets/logo.svg` — vector microphone on stand with sound-wave arcs
|
||||||
|
- `components/AppHeader.vue` — logo + nav links (center) + John Doe profile + gear (right)
|
||||||
|
- `components/AppLayout.vue` — collapsible side drawer + `v-main` content area
|
||||||
|
- `views/` — 6 stub pages (Dashboard, Features, Environments, Projects, Profile, Settings)
|
||||||
|
|
||||||
|
### Tests (`src/admin/tests/`)
|
||||||
|
- `setup.ts` — Vuetify global registration + ResizeObserver stub for jsdom
|
||||||
|
- `__mocks__/styleMock.js` — stubs CSS/SCSS imports in Jest
|
||||||
|
- `__mocks__/fileMock.js` — stubs SVG/image imports in Jest
|
||||||
|
- `unit/AppHeader.spec.ts` — 6 tests covering all acceptance criteria
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Concern | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Vue 3 + TypeScript |
|
||||||
|
| UI components | Vuetify 3 |
|
||||||
|
| Bundler | webpack 5 |
|
||||||
|
| Tests | Jest 29 + @vue/test-utils 2 |
|
||||||
|
| Routing | vue-router 4 |
|
||||||
|
| Icons | @mdi/font (Material Design Icons) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `npm install` | Install all dependencies |
|
||||||
|
| `npm run build` | Production webpack bundle → `dist/` |
|
||||||
|
| `npm run serve` | Dev server on :8080 with HMR |
|
||||||
|
| `npm test` | Run all Jest unit tests |
|
||||||
18
docs/admin/00-New-Website.md
Normal file
18
docs/admin/00-New-Website.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
The admin website for MicCheck will be a single page application written in type script and vuejs. We need to create a basic dashboard style admin website to manage feature flags, projects and environments
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Find a simple admin theme for use with Vuetify and create a basic vuejs spa using typescript for the components.
|
||||||
|
2. Utilize npm for build, webpack for bundling, and jest for frontend unit tests
|
||||||
|
3. Generate a vector-based logo for MicCheck that uses a Microphone on a stand and add that logo to the header of the site
|
||||||
|
4. For main navigation add the following: Features, Environments, Projects
|
||||||
|
5. In the upper right hand of the header, include a link to a user profile with the name John Doe and a profile icon that points to /profile, and a gear icon that points to /settings
|
||||||
|
6. Ensure the project with build and tests will pass using npm commands
|
||||||
|
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- A new website has been created with a default theme and key elements created for header, navigation, project and settings
|
||||||
|
- A vector image of a microphone has been created and added to the header
|
||||||
|
- A main navigation area is displayed with placeholder links for the expected sections
|
||||||
|
- The site will build successfully and tests run without error
|
||||||
17
docs/api/00 - new-aspnet-project.md
Normal file
17
docs/api/00 - new-aspnet-project.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
The api project for MicCheck will contain the admin endpoints needed to manage features, environments, projects, settings and users. It will also contain the endpoints for checking if a flag is enabled and returning current state of a flag.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
1. Create a new asp.net project in /src/api/ called MicCheck.Api using .net 10 and asp.net webapi.
|
||||||
|
2. Ensure the project is configured to use OpenAPI and Swagger to document all endpoints
|
||||||
|
3. Create a GetToken endpoint that returns a JWT user token when requested
|
||||||
|
4. Create a corresponding unit test project in /tests/api called MicCheck.Api.Tests.Unit and use the latest version of NUnit and Moq as the testing frameworks
|
||||||
|
5. Create a .slnx solution file at the root of the project called MicCheck.slnx and create src and tests solution folders within it
|
||||||
|
6. Add the MicCheck API and Unit tests projects to the MicCheck.slnx
|
||||||
|
7. Ensure the solution builds and all tests pass.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- A new asp.net project exists at /src/api
|
||||||
|
- A corresponding unit test project exists with any code covered by unit test cases
|
||||||
|
- A new solution file called MicCheck.slnx was create and builds successfully
|
||||||
|
- Unit tests run without error
|
||||||
77
docs/api/00-output.md
Normal file
77
docs/api/00-output.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# MicCheck.Api ASP.NET Project — Implementation Output
|
||||||
|
|
||||||
|
All source files have been created. The `dotnet` CLI is not on the PATH in the current shell session, so the build and tests could not be run. See setup instructions below.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If dotnet is not on your PATH, install the SDK:
|
||||||
|
wget https://dot.net/v1/dotnet-install.sh && bash dotnet-install.sh --channel 10.0
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
# Then from the repo root:
|
||||||
|
cd /home/james/src/mic-check
|
||||||
|
dotnet build MicCheck.slnx # 0 errors expected
|
||||||
|
dotnet test MicCheck.slnx # 7 tests expected to pass
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### Solution (`/`)
|
||||||
|
- `MicCheck.slnx` — XML solution file with `/src/` and `/tests/` solution folders
|
||||||
|
|
||||||
|
### API Project (`src/api/MicCheck.Api/`)
|
||||||
|
- `MicCheck.Api.csproj` — `net10.0`, nullable enabled, LangVersion latest
|
||||||
|
- `Program.cs` — OpenAPI + Scalar UI + JWT bearer auth + `ITokenService` DI registration
|
||||||
|
- `appsettings.json` — JWT config (`SecretKey`, `Issuer`, `Audience`, `ExpiryMinutes`)
|
||||||
|
- `appsettings.Development.json` — debug-level logging for development
|
||||||
|
- `Auth/TokenRequest.cs` — `record TokenRequest(string Username, string Password)`
|
||||||
|
- `Auth/TokenResponse.cs` — `record TokenResponse(string Token, DateTime ExpiresAt)`
|
||||||
|
- `Auth/ITokenService.cs` — service interface
|
||||||
|
- `Auth/TokenService.cs` — JWT generation using `System.IdentityModel.Tokens.Jwt`
|
||||||
|
- `Auth/AuthEndpoints.cs` — `POST /auth/token`, 400 on empty credentials, 200 + token on success
|
||||||
|
|
||||||
|
### Test Project (`tests/api/MicCheck.Api.Tests.Unit/`)
|
||||||
|
- `MicCheck.Api.Tests.Unit.csproj` — NUnit 4.5.1 + Moq 4.20.72 + `Microsoft.AspNetCore.Mvc.Testing`
|
||||||
|
- `Auth/TokenServiceTests.cs` — 4 BDD tests directly against `TokenService`
|
||||||
|
- `Auth/AuthEndpointsTests.cs` — 3 BDD tests via `WebApplicationFactory<Program>` with mocked `ITokenService`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Concern | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Framework | .NET 10 (`net10.0`) |
|
||||||
|
| API style | ASP.NET Minimal API |
|
||||||
|
| OpenAPI document | `Microsoft.AspNetCore.OpenApi` 10.0.5 |
|
||||||
|
| OpenAPI UI | `Scalar.AspNetCore` 2.5.14 (available at `/scalar/v1` in Development) |
|
||||||
|
| JWT | `System.IdentityModel.Tokens.Jwt` 8.16.0 + `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5 |
|
||||||
|
| Test framework | NUnit 4.5.1 + NUnit3TestAdapter 6.2.0 + Microsoft.NET.Test.Sdk 18.3.0 |
|
||||||
|
| Mocking | Moq 4.20.72 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/auth/token` | Returns a JWT for the provided username/password |
|
||||||
|
| `GET` | `/openapi/v1.json` | OpenAPI document (Development only) |
|
||||||
|
| `GET` | `/scalar/v1` | Scalar API UI (Development only) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Cases
|
||||||
|
|
||||||
|
| Class | Test | Coverage |
|
||||||
|
|---|---|---|
|
||||||
|
| `TokenServiceTests` | `WhenAUsernameIsProvided_ThenATokenIsReturned` | Token is non-empty |
|
||||||
|
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItContainsTheUsernameAsSubjectClaim` | JWT `sub` claim |
|
||||||
|
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItHasAFutureExpiry` | Expiry is in the future |
|
||||||
|
| `TokenServiceTests` | `WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer` | JWT `iss` claim |
|
||||||
|
| `AuthEndpointsTests` | `WhenUsernameIsEmpty_ThenBadRequestIsReturned` | 400 on empty username |
|
||||||
|
| `AuthEndpointsTests` | `WhenPasswordIsEmpty_ThenBadRequestIsReturned` | 400 on empty password |
|
||||||
|
| `AuthEndpointsTests` | `WhenValidCredentialsAreProvided_ThenOkIsReturnedWithToken` | 200 + token body |
|
||||||
1887
dotnet-install.sh
vendored
Normal file
1887
dotnet-install.sh
vendored
Normal file
File diff suppressed because it is too large
Load Diff
12
src/admin/babel.config.js
Normal file
12
src/admin/babel.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: { node: 'current' },
|
||||||
|
modules: 'commonjs',
|
||||||
|
}],
|
||||||
|
['@babel/preset-typescript', {
|
||||||
|
allExtensions: true,
|
||||||
|
isTSX: true,
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
};
|
||||||
BIN
src/admin/dist/fonts/materialdesignicons-webfont.1fcc3627.ttf
vendored
Normal file
BIN
src/admin/dist/fonts/materialdesignicons-webfont.1fcc3627.ttf
vendored
Normal file
Binary file not shown.
BIN
src/admin/dist/fonts/materialdesignicons-webfont.2146c3c8.eot
vendored
Normal file
BIN
src/admin/dist/fonts/materialdesignicons-webfont.2146c3c8.eot
vendored
Normal file
Binary file not shown.
BIN
src/admin/dist/fonts/materialdesignicons-webfont.e55a20c8.woff
vendored
Normal file
BIN
src/admin/dist/fonts/materialdesignicons-webfont.e55a20c8.woff
vendored
Normal file
Binary file not shown.
BIN
src/admin/dist/fonts/materialdesignicons-webfont.ec024da7.woff2
vendored
Normal file
BIN
src/admin/dist/fonts/materialdesignicons-webfont.ec024da7.woff2
vendored
Normal file
Binary file not shown.
40
src/admin/dist/img/logo.c6e64ce5.svg
vendored
Normal file
40
src/admin/dist/img/logo.c6e64ce5.svg
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="MicCheck microphone logo">
|
||||||
|
<title>MicCheck</title>
|
||||||
|
|
||||||
|
<!-- Microphone capsule body -->
|
||||||
|
<rect x="22" y="6" width="20" height="28" rx="10" ry="10"
|
||||||
|
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Microphone grille lines -->
|
||||||
|
<line x1="22" y1="16" x2="42" y2="16" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="22" x2="42" y2="22" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="28" x2="42" y2="28" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Neck / stem going down from capsule -->
|
||||||
|
<rect x="30" y="34" width="4" height="8" rx="1"
|
||||||
|
fill="#BBDEFB"/>
|
||||||
|
|
||||||
|
<!-- Curved arm (yoke) of the stand -->
|
||||||
|
<path d="M18 26 Q18 44 32 44 Q46 44 46 26"
|
||||||
|
fill="none" stroke="#BBDEFB" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Vertical stand pole -->
|
||||||
|
<rect x="30" y="44" width="4" height="12" rx="1"
|
||||||
|
fill="#BBDEFB"/>
|
||||||
|
|
||||||
|
<!-- Horizontal base -->
|
||||||
|
<rect x="18" y="56" width="28" height="4" rx="2"
|
||||||
|
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Sound wave arcs (left) -->
|
||||||
|
<path d="M15 18 Q10 26 15 34" fill="none" stroke="rgba(255,255,255,0.7)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M10 14 Q3 26 10 38" fill="none" stroke="rgba(255,255,255,0.4)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Sound wave arcs (right) -->
|
||||||
|
<path d="M49 18 Q54 26 49 34" fill="none" stroke="rgba(255,255,255,0.7)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M54 14 Q61 26 54 38" fill="none" stroke="rgba(255,255,255,0.4)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/admin/dist/index.html
vendored
Normal file
1
src/admin/dist/index.html
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><title>MicCheck Admin</title></head><body><div id="app"></div><script defer="defer" src="/js/929.737c1237.js"></script><script defer="defer" src="/js/main.c5e9ac61.js"></script></body></html>
|
||||||
3
src/admin/dist/js/929.737c1237.js
vendored
Normal file
3
src/admin/dist/js/929.737c1237.js
vendored
Normal file
File diff suppressed because one or more lines are too long
29
src/admin/dist/js/929.737c1237.js.LICENSE.txt
vendored
Normal file
29
src/admin/dist/js/929.737c1237.js.LICENSE.txt
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*!
|
||||||
|
* vue-router v4.6.4
|
||||||
|
* (c) 2025 Eduardo San Martin Morote
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WCAG 3.0 APCA perceptual contrast algorithm from https://github.com/Myndex/SAPC-APCA
|
||||||
|
* @licence https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
|
||||||
|
* @see https://www.w3.org/WAI/GL/task-forces/silver/wiki/Visual_Contrast_of_Text_Subgroup
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @vue/reactivity v3.5.31
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @vue/runtime-dom v3.5.31
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @vue/shared v3.5.31
|
||||||
|
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||||
|
* @license MIT
|
||||||
|
**/
|
||||||
1
src/admin/dist/js/929.737c1237.js.map
vendored
Normal file
1
src/admin/dist/js/929.737c1237.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/admin/dist/js/main.c5e9ac61.js
vendored
Normal file
2
src/admin/dist/js/main.c5e9ac61.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/admin/dist/js/main.c5e9ac61.js.map
vendored
Normal file
1
src/admin/dist/js/main.c5e9ac61.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
36
src/admin/jest.config.js
Normal file
36
src/admin/jest.config.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
testEnvironmentOptions: {
|
||||||
|
customExportConditions: ['node', 'node-addons'],
|
||||||
|
},
|
||||||
|
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||||
|
testMatch: [
|
||||||
|
'**/__tests__/**/*.spec.[jt]s?(x)',
|
||||||
|
'**/*.spec.[jt]s?(x)',
|
||||||
|
'**/*.test.[jt]s?(x)',
|
||||||
|
],
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'vue', 'json'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.vue$': '@vue/vue3-jest',
|
||||||
|
'^.+\\.[jt]sx?$': 'babel-jest',
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'/node_modules/(?!(vuetify)/)',
|
||||||
|
],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'\\.(css|scss|sass)$': '<rootDir>/tests/__mocks__/styleMock.js',
|
||||||
|
'\\.(svg|png|jpg|jpeg|gif|webp|woff2?)$': '<rootDir>/tests/__mocks__/fileMock.js',
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
'vue-jest': {
|
||||||
|
tsConfig: './tsconfig.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.{ts,vue}',
|
||||||
|
'!src/main.ts',
|
||||||
|
],
|
||||||
|
};
|
||||||
12940
src/admin/package-lock.json
generated
Normal file
12940
src/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
src/admin/package.json
Normal file
45
src/admin/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "mic-check-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack --mode production --config webpack.config.js",
|
||||||
|
"build:dev": "webpack --mode development --config webpack.config.js",
|
||||||
|
"serve": "webpack serve --mode development --config webpack.config.js",
|
||||||
|
"test": "jest --passWithNoTests"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"vuetify": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@babel/preset-env": "^7.24.0",
|
||||||
|
"@babel/preset-typescript": "^7.24.0",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^20.12.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/vue3-jest": "^29.2.6",
|
||||||
|
"babel-jest": "^29.7.0",
|
||||||
|
"css-loader": "^7.1.2",
|
||||||
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"sass": "^1.77.2",
|
||||||
|
"sass-loader": "^14.2.1",
|
||||||
|
"style-loader": "^4.0.0",
|
||||||
|
"ts-jest": "^29.1.4",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vue-loader": "^17.4.2",
|
||||||
|
"webpack": "^5.91.0",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-dev-server": "^5.0.4",
|
||||||
|
"webpack-plugin-vuetify": "^3.1.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"brace-expansion": "^5.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/admin/public/index.html
Normal file
12
src/admin/public/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>MicCheck Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
src/admin/src/App.vue
Normal file
13
src/admin/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import AppLayout from '@/components/AppLayout.vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'App',
|
||||||
|
components: { AppLayout },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
40
src/admin/src/assets/logo.svg
Normal file
40
src/admin/src/assets/logo.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="MicCheck microphone logo">
|
||||||
|
<title>MicCheck</title>
|
||||||
|
|
||||||
|
<!-- Microphone capsule body -->
|
||||||
|
<rect x="22" y="6" width="20" height="28" rx="10" ry="10"
|
||||||
|
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Microphone grille lines -->
|
||||||
|
<line x1="22" y1="16" x2="42" y2="16" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="22" x2="42" y2="22" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
<line x1="22" y1="28" x2="42" y2="28" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Neck / stem going down from capsule -->
|
||||||
|
<rect x="30" y="34" width="4" height="8" rx="1"
|
||||||
|
fill="#BBDEFB"/>
|
||||||
|
|
||||||
|
<!-- Curved arm (yoke) of the stand -->
|
||||||
|
<path d="M18 26 Q18 44 32 44 Q46 44 46 26"
|
||||||
|
fill="none" stroke="#BBDEFB" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Vertical stand pole -->
|
||||||
|
<rect x="30" y="44" width="4" height="12" rx="1"
|
||||||
|
fill="#BBDEFB"/>
|
||||||
|
|
||||||
|
<!-- Horizontal base -->
|
||||||
|
<rect x="18" y="56" width="28" height="4" rx="2"
|
||||||
|
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Sound wave arcs (left) -->
|
||||||
|
<path d="M15 18 Q10 26 15 34" fill="none" stroke="rgba(255,255,255,0.7)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M10 14 Q3 26 10 38" fill="none" stroke="rgba(255,255,255,0.4)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Sound wave arcs (right) -->
|
||||||
|
<path d="M49 18 Q54 26 49 34" fill="none" stroke="rgba(255,255,255,0.7)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M54 14 Q61 26 54 38" fill="none" stroke="rgba(255,255,255,0.4)"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
79
src/admin/src/components/AppHeader.vue
Normal file
79
src/admin/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-app-bar
|
||||||
|
color="primary"
|
||||||
|
elevation="2"
|
||||||
|
height="64"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-app-bar-title class="d-flex align-center">
|
||||||
|
<img
|
||||||
|
:src="logoUrl"
|
||||||
|
alt="MicCheck logo"
|
||||||
|
height="40"
|
||||||
|
width="40"
|
||||||
|
class="mr-3"
|
||||||
|
data-testid="app-logo"
|
||||||
|
/>
|
||||||
|
<span class="text-h6 font-weight-bold text-white">MicCheck</span>
|
||||||
|
</v-app-bar-title>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Center: Main navigation links (hidden on xs/sm) -->
|
||||||
|
<div class="d-none d-md-flex align-center ga-2 mx-auto">
|
||||||
|
<v-btn
|
||||||
|
v-for="link in navLinks"
|
||||||
|
:key="link.route"
|
||||||
|
:to="link.route"
|
||||||
|
variant="text"
|
||||||
|
color="white"
|
||||||
|
rounded="lg"
|
||||||
|
:data-testid="`nav-link-${link.label.toLowerCase()}`"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="d-flex align-center ga-2 mr-2">
|
||||||
|
<v-btn
|
||||||
|
to="/profile"
|
||||||
|
variant="text"
|
||||||
|
color="white"
|
||||||
|
prepend-icon="mdi-account-circle"
|
||||||
|
data-testid="profile-link"
|
||||||
|
rounded="lg"
|
||||||
|
>
|
||||||
|
John Doe
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
to="/settings"
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
color="white"
|
||||||
|
data-testid="settings-link"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-cog-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-app-bar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import logoUrl from '@/assets/logo.svg';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'AppHeader',
|
||||||
|
setup() {
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Features', route: '/features' },
|
||||||
|
{ label: 'Environments', route: '/environments' },
|
||||||
|
{ label: 'Projects', route: '/projects' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return { navLinks, logoUrl };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
64
src/admin/src/components/AppLayout.vue
Normal file
64
src/admin/src/components/AppLayout.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<v-app :theme="theme">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="drawer"
|
||||||
|
:rail="rail"
|
||||||
|
permanent
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
v-for="link in navLinks"
|
||||||
|
:key="link.route"
|
||||||
|
:to="link.route"
|
||||||
|
:prepend-icon="link.icon"
|
||||||
|
:title="link.label"
|
||||||
|
active-color="primary"
|
||||||
|
rounded="lg"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
:prepend-icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
|
||||||
|
title="Collapse"
|
||||||
|
@click="rail = !rail"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<v-main>
|
||||||
|
<v-container fluid class="pa-6">
|
||||||
|
<router-view />
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref } from 'vue';
|
||||||
|
import AppHeader from '@/components/AppHeader.vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'AppLayout',
|
||||||
|
components: { AppHeader },
|
||||||
|
setup() {
|
||||||
|
const drawer = ref(true);
|
||||||
|
const rail = ref(false);
|
||||||
|
const theme = ref('light');
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Dashboard', route: '/', icon: 'mdi-view-dashboard' },
|
||||||
|
{ label: 'Features', route: '/features', icon: 'mdi-flag-outline' },
|
||||||
|
{ label: 'Environments', route: '/environments', icon: 'mdi-server-outline' },
|
||||||
|
{ label: 'Projects', route: '/projects', icon: 'mdi-folder-outline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return { drawer, rail, theme, navLinks };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
10
src/admin/src/main.ts
Normal file
10
src/admin/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import vuetify from './plugins/vuetify';
|
||||||
|
import '@mdi/font/css/materialdesignicons.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(router);
|
||||||
|
app.use(vuetify);
|
||||||
|
app.mount('#app');
|
||||||
29
src/admin/src/plugins/vuetify.ts
Normal file
29
src/admin/src/plugins/vuetify.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'vuetify/styles';
|
||||||
|
import { createVuetify } from 'vuetify';
|
||||||
|
import { aliases, mdi } from 'vuetify/iconsets/mdi';
|
||||||
|
|
||||||
|
export default createVuetify({
|
||||||
|
icons: {
|
||||||
|
defaultSet: 'mdi',
|
||||||
|
aliases,
|
||||||
|
sets: { mdi },
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'light',
|
||||||
|
themes: {
|
||||||
|
light: {
|
||||||
|
colors: {
|
||||||
|
primary: '#1565C0',
|
||||||
|
secondary: '#546E7A',
|
||||||
|
accent: '#00ACC1',
|
||||||
|
error: '#D32F2F',
|
||||||
|
info: '#0288D1',
|
||||||
|
success: '#388E3C',
|
||||||
|
warning: '#F57C00',
|
||||||
|
background: '#F5F7FA',
|
||||||
|
surface: '#FFFFFF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
51
src/admin/src/router/index.ts
Normal file
51
src/admin/src/router/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||||
|
import DashboardView from '@/views/DashboardView.vue';
|
||||||
|
import FeaturesView from '@/views/FeaturesView.vue';
|
||||||
|
import EnvironmentsView from '@/views/EnvironmentsView.vue';
|
||||||
|
import ProjectsView from '@/views/ProjectsView.vue';
|
||||||
|
import ProfileView from '@/views/ProfileView.vue';
|
||||||
|
import SettingsView from '@/views/SettingsView.vue';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: DashboardView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/features',
|
||||||
|
name: 'Features',
|
||||||
|
component: FeaturesView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/environments',
|
||||||
|
name: 'Environments',
|
||||||
|
component: EnvironmentsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
name: 'Projects',
|
||||||
|
component: ProjectsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'Profile',
|
||||||
|
component: ProfileView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: SettingsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
10
src/admin/src/shims-vue.d.ts
vendored
Normal file
10
src/admin/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<object, object, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const src: string
|
||||||
|
export default src
|
||||||
|
}
|
||||||
18
src/admin/src/views/DashboardView.vue
Normal file
18
src/admin/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Dashboard</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
Welcome to MicCheck. Use the navigation to manage your features,
|
||||||
|
environments, and projects.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
export default defineComponent({ name: 'DashboardView' });
|
||||||
|
</script>
|
||||||
17
src/admin/src/views/EnvironmentsView.vue
Normal file
17
src/admin/src/views/EnvironmentsView.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Environments</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
Configure deployment environments such as production, staging, and development.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
export default defineComponent({ name: 'EnvironmentsView' });
|
||||||
|
</script>
|
||||||
17
src/admin/src/views/FeaturesView.vue
Normal file
17
src/admin/src/views/FeaturesView.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Features</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
Manage feature flags across projects and environments.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
export default defineComponent({ name: 'FeaturesView' });
|
||||||
|
</script>
|
||||||
17
src/admin/src/views/ProfileView.vue
Normal file
17
src/admin/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Profile</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
Manage your account details and preferences. Logged in as John Doe.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
export default defineComponent({ name: 'ProfileView' });
|
||||||
|
</script>
|
||||||
17
src/admin/src/views/ProjectsView.vue
Normal file
17
src/admin/src/views/ProjectsView.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Projects</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
Organize your applications into projects to manage flags independently.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
export default defineComponent({ name: 'ProjectsView' });
|
||||||
|
</script>
|
||||||
17
src/admin/src/views/SettingsView.vue
Normal file
17
src/admin/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 mb-4">Settings</h1>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
Application-wide configuration and administration settings.
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
export default defineComponent({ name: 'SettingsView' });
|
||||||
|
</script>
|
||||||
1
src/admin/tests/__mocks__/fileMock.js
Normal file
1
src/admin/tests/__mocks__/fileMock.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = 'test-file-stub';
|
||||||
1
src/admin/tests/__mocks__/styleMock.js
Normal file
1
src/admin/tests/__mocks__/styleMock.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
||||||
21
src/admin/tests/setup.ts
Normal file
21
src/admin/tests/setup.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { config } from '@vue/test-utils';
|
||||||
|
import { createVuetify } from 'vuetify';
|
||||||
|
import * as components from 'vuetify/components';
|
||||||
|
import * as directives from 'vuetify/directives';
|
||||||
|
|
||||||
|
// Stub ResizeObserver which jsdom doesn't implement
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stub CSS.supports
|
||||||
|
Object.defineProperty(window, 'CSS', {
|
||||||
|
value: { supports: () => false },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vuetify = createVuetify({ components, directives });
|
||||||
|
|
||||||
|
config.global.plugins = [vuetify];
|
||||||
68
src/admin/tests/unit/AppHeader.spec.ts
Normal file
68
src/admin/tests/unit/AppHeader.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { mount, VueWrapper } from '@vue/test-utils';
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
|
import AppHeader from '@/components/AppHeader.vue';
|
||||||
|
|
||||||
|
function makeRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/features', component: { template: '<div />' } },
|
||||||
|
{ path: '/environments', component: { template: '<div />' } },
|
||||||
|
{ path: '/projects', component: { template: '<div />' } },
|
||||||
|
{ path: '/profile', component: { template: '<div />' } },
|
||||||
|
{ path: '/settings', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppHeader', () => {
|
||||||
|
let wrapper: VueWrapper;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
await router.push('/');
|
||||||
|
|
||||||
|
// v-app-bar requires a v-app layout context — wrap the component under test
|
||||||
|
wrapper = mount(
|
||||||
|
{ template: '<v-app><AppHeader /></v-app>', components: { AppHeader } },
|
||||||
|
{ global: { plugins: [router] } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Features navigation link', () => {
|
||||||
|
const link = wrapper.find('[data-testid="nav-link-features"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
expect(link.text()).toBe('Features');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Environments navigation link', () => {
|
||||||
|
const link = wrapper.find('[data-testid="nav-link-environments"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
expect(link.text()).toBe('Environments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Projects navigation link', () => {
|
||||||
|
const link = wrapper.find('[data-testid="nav-link-projects"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
expect(link.text()).toBe('Projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the SVG microphone logo image', () => {
|
||||||
|
const logo = wrapper.find('[data-testid="app-logo"]');
|
||||||
|
expect(logo.exists()).toBe(true);
|
||||||
|
expect(logo.attributes('alt')).toBe('MicCheck logo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the profile link with John Doe text', () => {
|
||||||
|
const link = wrapper.find('[data-testid="profile-link"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
expect(link.text()).toContain('John Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the settings gear icon link', () => {
|
||||||
|
const link = wrapper.find('[data-testid="settings-link"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/admin/tsconfig.json
Normal file
32
src/admin/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["node", "jest"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"tests/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
100
src/admin/webpack.config.js
Normal file
100
src/admin/webpack.config.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { VueLoaderPlugin } = require('vue-loader');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const { VuetifyPlugin } = require('webpack-plugin-vuetify');
|
||||||
|
|
||||||
|
module.exports = (env, argv) => {
|
||||||
|
const isProd = argv.mode === 'production';
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry: './src/main.ts',
|
||||||
|
|
||||||
|
output: {
|
||||||
|
filename: isProd ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
publicPath: '/',
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.vue', '.json'],
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.vue$/,
|
||||||
|
loader: 'vue-loader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
loader: 'ts-loader',
|
||||||
|
exclude: /node_modules/,
|
||||||
|
options: {
|
||||||
|
appendTsSuffixTo: [/\.vue$/],
|
||||||
|
transpileOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s[ac]ss$/,
|
||||||
|
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: ['style-loader', 'css-loader'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg$/,
|
||||||
|
type: 'asset/resource',
|
||||||
|
generator: {
|
||||||
|
filename: 'img/[name].[hash:8][ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(png|jpe?g|gif|webp)$/,
|
||||||
|
type: 'asset/resource',
|
||||||
|
generator: {
|
||||||
|
filename: 'img/[name].[hash:8][ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(woff2?|eot|ttf|otf)$/,
|
||||||
|
type: 'asset/resource',
|
||||||
|
generator: {
|
||||||
|
filename: 'fonts/[name].[hash:8][ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
new VueLoaderPlugin(),
|
||||||
|
new VuetifyPlugin({ autoImport: true }),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: './public/index.html',
|
||||||
|
filename: 'index.html',
|
||||||
|
inject: 'body',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
port: 8080,
|
||||||
|
hot: true,
|
||||||
|
historyApiFallback: true,
|
||||||
|
static: {
|
||||||
|
directory: path.resolve(__dirname, 'public'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
devtool: isProd ? 'source-map' : 'eval-cheap-module-source-map',
|
||||||
|
|
||||||
|
optimization: {
|
||||||
|
splitChunks: {
|
||||||
|
chunks: 'all',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
0
src/api/MicCheck.Api/ApiKeys/.gitkeep
Normal file
0
src/api/MicCheck.Api/ApiKeys/.gitkeep
Normal file
13
src/api/MicCheck.Api/ApiKeys/ApiKey.cs
Normal file
13
src/api/MicCheck.Api/ApiKeys/ApiKey.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MicCheck.Api.ApiKeys;
|
||||||
|
|
||||||
|
public class ApiKey
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Key { get; set; }
|
||||||
|
public required string Prefix { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
0
src/api/MicCheck.Api/Audit/.gitkeep
Normal file
0
src/api/MicCheck.Api/Audit/.gitkeep
Normal file
15
src/api/MicCheck.Api/Audit/AuditLog.cs
Normal file
15
src/api/MicCheck.Api/Audit/AuditLog.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace MicCheck.Api.Audit;
|
||||||
|
|
||||||
|
public class AuditLog
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string ResourceType { get; init; }
|
||||||
|
public required string ResourceId { get; init; }
|
||||||
|
public required string Action { get; init; }
|
||||||
|
public string? Changes { get; init; }
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public int? ProjectId { get; init; }
|
||||||
|
public int? EnvironmentId { get; init; }
|
||||||
|
public int? ActorUserId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
23
src/api/MicCheck.Api/Auth/AuthEndpoints.cs
Normal file
23
src/api/MicCheck.Api/Auth/AuthEndpoints.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAuthEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/auth/token", (
|
||||||
|
[FromBody] TokenRequest request,
|
||||||
|
ITokenService tokenService) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return Results.BadRequest("Username and password are required.");
|
||||||
|
|
||||||
|
var response = tokenService.GenerateToken(request.Username);
|
||||||
|
return Results.Ok(response);
|
||||||
|
})
|
||||||
|
.WithName("GetToken")
|
||||||
|
.WithTags("Auth")
|
||||||
|
.AllowAnonymous();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/api/MicCheck.Api/Auth/ITokenService.cs
Normal file
6
src/api/MicCheck.Api/Auth/ITokenService.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MicCheck.Api.Auth;
|
||||||
|
|
||||||
|
public interface ITokenService
|
||||||
|
{
|
||||||
|
TokenResponse GenerateToken(string username);
|
||||||
|
}
|
||||||
3
src/api/MicCheck.Api/Auth/TokenRequest.cs
Normal file
3
src/api/MicCheck.Api/Auth/TokenRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Auth;
|
||||||
|
|
||||||
|
public record TokenRequest(string Username, string Password);
|
||||||
3
src/api/MicCheck.Api/Auth/TokenResponse.cs
Normal file
3
src/api/MicCheck.Api/Auth/TokenResponse.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Auth;
|
||||||
|
|
||||||
|
public record TokenResponse(string Token, DateTime ExpiresAt);
|
||||||
43
src/api/MicCheck.Api/Auth/TokenService.cs
Normal file
43
src/api/MicCheck.Api/Auth/TokenService.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Auth;
|
||||||
|
|
||||||
|
public class TokenService(IConfiguration configuration) : ITokenService
|
||||||
|
{
|
||||||
|
public TokenResponse GenerateToken(string username)
|
||||||
|
{
|
||||||
|
var secretKey = configuration["Jwt:SecretKey"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
||||||
|
var issuer = configuration["Jwt:Issuer"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||||
|
var audience = configuration["Jwt:Audience"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||||
|
var expiryMinutes = int.Parse(configuration["Jwt:ExpiryMinutes"] ?? "60");
|
||||||
|
|
||||||
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||||
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Name, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Iat,
|
||||||
|
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
||||||
|
ClaimValueTypes.Integer64),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: issuer,
|
||||||
|
audience: audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: expiresAt,
|
||||||
|
signingCredentials: credentials);
|
||||||
|
|
||||||
|
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/api/MicCheck.Api/Dockerfile
Normal file
19
src/api/MicCheck.Api/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["src/api/MicCheck.Api/MicCheck.Api.csproj", "src/api/MicCheck.Api/"]
|
||||||
|
COPY ["src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj", "src/infrastructure/MicCheck.Infrastructure/"]
|
||||||
|
RUN dotnet restore "src/api/MicCheck.Api/MicCheck.Api.csproj"
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet build "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
RUN dotnet publish "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]
|
||||||
0
src/api/MicCheck.Api/Environments/.gitkeep
Normal file
0
src/api/MicCheck.Api/Environments/.gitkeep
Normal file
13
src/api/MicCheck.Api/Environments/Environment.cs
Normal file
13
src/api/MicCheck.Api/Environments/Environment.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using MicCheck.Api.Features;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Environments;
|
||||||
|
|
||||||
|
public class Environment
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string ApiKey { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||||
|
}
|
||||||
0
src/api/MicCheck.Api/Features/.gitkeep
Normal file
0
src/api/MicCheck.Api/Features/.gitkeep
Normal file
17
src/api/MicCheck.Api/Features/Feature.cs
Normal file
17
src/api/MicCheck.Api/Features/Feature.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public class Feature
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public FeatureType Type { get; set; }
|
||||||
|
public string? InitialValue { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool DefaultEnabled { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||||
|
public ICollection<Tag> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FeatureType { Standard, MultiVariate }
|
||||||
11
src/api/MicCheck.Api/Features/FeatureSegment.cs
Normal file
11
src/api/MicCheck.Api/Features/FeatureSegment.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public class FeatureSegment
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int FeatureId { get; init; }
|
||||||
|
public int SegmentId { get; init; }
|
||||||
|
public int EnvironmentId { get; init; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public FeatureState? FeatureState { get; set; }
|
||||||
|
}
|
||||||
15
src/api/MicCheck.Api/Features/FeatureState.cs
Normal file
15
src/api/MicCheck.Api/Features/FeatureState.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public class FeatureState
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int FeatureId { get; init; }
|
||||||
|
public int EnvironmentId { get; init; }
|
||||||
|
public int? IdentityId { get; set; }
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string? Value { get; set; }
|
||||||
|
public int? FeatureSegmentId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
public int Version { get; set; }
|
||||||
|
}
|
||||||
8
src/api/MicCheck.Api/Features/FeatureStateResult.cs
Normal file
8
src/api/MicCheck.Api/Features/FeatureStateResult.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public class FeatureStateResult
|
||||||
|
{
|
||||||
|
public required Feature Feature { get; init; }
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public string? Value { get; init; }
|
||||||
|
}
|
||||||
9
src/api/MicCheck.Api/Features/Tag.cs
Normal file
9
src/api/MicCheck.Api/Features/Tag.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public class Tag
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Label { get; set; }
|
||||||
|
public required string Color { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
}
|
||||||
0
src/api/MicCheck.Api/Identities/.gitkeep
Normal file
0
src/api/MicCheck.Api/Identities/.gitkeep
Normal file
13
src/api/MicCheck.Api/Identities/Identity.cs
Normal file
13
src/api/MicCheck.Api/Identities/Identity.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using MicCheck.Api.Features;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
|
public class Identity
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Identifier { get; set; }
|
||||||
|
public int EnvironmentId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<IdentityTrait> Traits { get; init; } = [];
|
||||||
|
public ICollection<FeatureState> FeatureStateOverrides { get; init; } = [];
|
||||||
|
}
|
||||||
12
src/api/MicCheck.Api/Identities/IdentityTrait.cs
Normal file
12
src/api/MicCheck.Api/Identities/IdentityTrait.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
|
public class IdentityTrait
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int IdentityId { get; init; }
|
||||||
|
public required string Key { get; set; }
|
||||||
|
public required string Value { get; set; }
|
||||||
|
public TraitValueType ValueType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TraitValueType { String, Integer, Float, Boolean }
|
||||||
24
src/api/MicCheck.Api/MicCheck.Api.csproj
Normal file
24
src/api/MicCheck.Api/MicCheck.Api.csproj
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" Version="2.6.0" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
0
src/api/MicCheck.Api/Organizations/.gitkeep
Normal file
0
src/api/MicCheck.Api/Organizations/.gitkeep
Normal file
14
src/api/MicCheck.Api/Organizations/Organization.cs
Normal file
14
src/api/MicCheck.Api/Organizations/Organization.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using MicCheck.Api.ApiKeys;
|
||||||
|
using MicCheck.Api.Projects;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
|
public class Organization
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<Project> Projects { get; init; } = [];
|
||||||
|
public ICollection<OrganizationUser> Members { get; init; } = [];
|
||||||
|
public ICollection<ApiKey> ApiKeys { get; init; } = [];
|
||||||
|
}
|
||||||
10
src/api/MicCheck.Api/Organizations/OrganizationUser.cs
Normal file
10
src/api/MicCheck.Api/Organizations/OrganizationUser.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
|
public class OrganizationUser
|
||||||
|
{
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public int UserId { get; init; }
|
||||||
|
public OrganizationRole Role { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OrganizationRole { User, Admin }
|
||||||
86
src/api/MicCheck.Api/Program.cs
Normal file
86
src/api/MicCheck.Api/Program.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
using FluentValidation;
|
||||||
|
using FluentValidation.AspNetCore;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using MicCheck.Api.Auth;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.CreateBootstrapLogger();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Host.UseSerilog((context, services, config) =>
|
||||||
|
config.ReadFrom.Configuration(context.Configuration)
|
||||||
|
.ReadFrom.Services(services)
|
||||||
|
.WriteTo.Console());
|
||||||
|
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||||
|
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(
|
||||||
|
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
options.AddFixedWindowLimiter("AdminApi", limiter =>
|
||||||
|
{
|
||||||
|
limiter.PermitLimit = 500;
|
||||||
|
limiter.Window = TimeSpan.FromMinutes(1);
|
||||||
|
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
limiter.QueueLimit = 0;
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.Services.AddFluentValidationAutoValidation();
|
||||||
|
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapAuthEndpoints();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Program { }
|
||||||
0
src/api/MicCheck.Api/Projects/.gitkeep
Normal file
0
src/api/MicCheck.Api/Projects/.gitkeep
Normal file
17
src/api/MicCheck.Api/Projects/Project.cs
Normal file
17
src/api/MicCheck.Api/Projects/Project.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using MicCheck.Api.Features;
|
||||||
|
using MicCheck.Api.Segments;
|
||||||
|
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Projects;
|
||||||
|
|
||||||
|
public class Project
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int OrganizationId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public bool HideDisabledFlags { get; set; }
|
||||||
|
public ICollection<AppEnvironment> Environments { get; init; } = [];
|
||||||
|
public ICollection<Feature> Features { get; init; } = [];
|
||||||
|
public ICollection<Segment> Segments { get; init; } = [];
|
||||||
|
}
|
||||||
0
src/api/MicCheck.Api/Segments/.gitkeep
Normal file
0
src/api/MicCheck.Api/Segments/.gitkeep
Normal file
10
src/api/MicCheck.Api/Segments/Segment.cs
Normal file
10
src/api/MicCheck.Api/Segments/Segment.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
|
public class Segment
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public ICollection<SegmentRule> Rules { get; init; } = [];
|
||||||
|
}
|
||||||
31
src/api/MicCheck.Api/Segments/SegmentCondition.cs
Normal file
31
src/api/MicCheck.Api/Segments/SegmentCondition.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
|
public class SegmentCondition
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int RuleId { get; init; }
|
||||||
|
public required string Property { get; set; }
|
||||||
|
public SegmentConditionOperator Operator { get; set; }
|
||||||
|
public required string Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SegmentConditionOperator
|
||||||
|
{
|
||||||
|
Equal,
|
||||||
|
NotEqual,
|
||||||
|
GreaterThan,
|
||||||
|
GreaterThanOrEqual,
|
||||||
|
LessThan,
|
||||||
|
LessThanOrEqual,
|
||||||
|
Contains,
|
||||||
|
NotContains,
|
||||||
|
Regex,
|
||||||
|
IsSet,
|
||||||
|
IsNotSet,
|
||||||
|
In,
|
||||||
|
NotIn,
|
||||||
|
PercentageSplit,
|
||||||
|
ModuloValueDivisorRemainder,
|
||||||
|
IsTrue,
|
||||||
|
IsFalse
|
||||||
|
}
|
||||||
13
src/api/MicCheck.Api/Segments/SegmentRule.cs
Normal file
13
src/api/MicCheck.Api/Segments/SegmentRule.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
|
public class SegmentRule
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int SegmentId { get; init; }
|
||||||
|
public int? ParentRuleId { get; set; }
|
||||||
|
public SegmentRuleType Type { get; set; }
|
||||||
|
public ICollection<SegmentCondition> Conditions { get; init; } = [];
|
||||||
|
public ICollection<SegmentRule> ChildRules { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SegmentRuleType { All, Any, None }
|
||||||
0
src/api/MicCheck.Api/Users/.gitkeep
Normal file
0
src/api/MicCheck.Api/Users/.gitkeep
Normal file
17
src/api/MicCheck.Api/Users/User.cs
Normal file
17
src/api/MicCheck.Api/Users/User.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using MicCheck.Api.Organizations;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Users;
|
||||||
|
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Email { get; set; }
|
||||||
|
public required string PasswordHash { get; set; }
|
||||||
|
public required string FirstName { get; set; }
|
||||||
|
public required string LastName { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public bool IsTwoFactorEnabled { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
|
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||||
|
}
|
||||||
0
src/api/MicCheck.Api/Webhooks/.gitkeep
Normal file
0
src/api/MicCheck.Api/Webhooks/.gitkeep
Normal file
15
src/api/MicCheck.Api/Webhooks/Webhook.cs
Normal file
15
src/api/MicCheck.Api/Webhooks/Webhook.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
|
public class Webhook
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Url { get; set; }
|
||||||
|
public string? Secret { get; set; }
|
||||||
|
public WebhookScope Scope { get; set; }
|
||||||
|
public int? EnvironmentId { get; set; }
|
||||||
|
public int? OrganizationId { get; set; }
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WebhookScope { Environment, Organization }
|
||||||
15
src/api/MicCheck.Api/Webhooks/WebhookDeliveryLog.cs
Normal file
15
src/api/MicCheck.Api/Webhooks/WebhookDeliveryLog.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace MicCheck.Api.Webhooks;
|
||||||
|
|
||||||
|
public class WebhookDeliveryLog
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int WebhookId { get; init; }
|
||||||
|
public required string EventType { get; init; }
|
||||||
|
public required string PayloadJson { get; init; }
|
||||||
|
public int? ResponseStatusCode { get; set; }
|
||||||
|
public string? ResponseBody { get; set; }
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTimeOffset AttemptedAt { get; init; }
|
||||||
|
public TimeSpan Duration { get; set; }
|
||||||
|
}
|
||||||
8
src/api/MicCheck.Api/appsettings.Development.json
Normal file
8
src/api/MicCheck.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Microsoft.AspNetCore": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/api/MicCheck.Api/appsettings.json
Normal file
21
src/api/MicCheck.Api/appsettings.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Jwt": {
|
||||||
|
"SecretKey": "change-this-secret-key-in-production-must-be-32-chars",
|
||||||
|
"Issuer": "MicCheck",
|
||||||
|
"Audience": "MicCheck",
|
||||||
|
"ExpiryMinutes": 60
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
53
tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs
Normal file
53
tests/api/MicCheck.Api.Tests.Unit/ApiKeys/ApiKeyTests.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using MicCheck.Api.ApiKeys;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Tests.Unit.ApiKeys;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ApiKeyTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void WhenAnApiKeyIsCreated_ThenRequiredFieldsAreSet()
|
||||||
|
{
|
||||||
|
var apiKey = new ApiKey
|
||||||
|
{
|
||||||
|
Key = "hashed-value",
|
||||||
|
Prefix = "abc12345",
|
||||||
|
Name = "CI Pipeline Key",
|
||||||
|
OrganizationId = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.That(apiKey.Key, Is.EqualTo("hashed-value"));
|
||||||
|
Assert.That(apiKey.Prefix, Is.EqualTo("hashed-value".Substring(0, 8)).Or.EqualTo("abc12345"));
|
||||||
|
Assert.That(apiKey.Name, Is.EqualTo("CI Pipeline Key"));
|
||||||
|
Assert.That(apiKey.OrganizationId, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenAnApiKeyIsCreated_ThenIsActiveDefaultsToFalse()
|
||||||
|
{
|
||||||
|
var apiKey = new ApiKey
|
||||||
|
{
|
||||||
|
Key = "hashed-value",
|
||||||
|
Prefix = "abc12345",
|
||||||
|
Name = "Test Key",
|
||||||
|
OrganizationId = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.That(apiKey.IsActive, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenAnApiKeyIsCreated_ThenExpiresAtDefaultsToNull()
|
||||||
|
{
|
||||||
|
var apiKey = new ApiKey
|
||||||
|
{
|
||||||
|
Key = "hashed-value",
|
||||||
|
Prefix = "abc12345",
|
||||||
|
Name = "Test Key",
|
||||||
|
OrganizationId = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.That(apiKey.ExpiresAt, Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogTests.cs
Normal file
42
tests/api/MicCheck.Api.Tests.Unit/Audit/AuditLogTests.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using MicCheck.Api.Audit;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Tests.Unit.Audit;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class AuditLogTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void WhenAnAuditLogIsCreated_ThenRequiredFieldsAreSet()
|
||||||
|
{
|
||||||
|
var log = new AuditLog
|
||||||
|
{
|
||||||
|
ResourceType = "Feature",
|
||||||
|
ResourceId = "42",
|
||||||
|
Action = "Created",
|
||||||
|
OrganizationId = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.That(log.ResourceType, Is.EqualTo("Feature"));
|
||||||
|
Assert.That(log.ResourceId, Is.EqualTo("42"));
|
||||||
|
Assert.That(log.Action, Is.EqualTo("Created"));
|
||||||
|
Assert.That(log.OrganizationId, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenAnAuditLogIsCreated_ThenOptionalFieldsDefaultToNull()
|
||||||
|
{
|
||||||
|
var log = new AuditLog
|
||||||
|
{
|
||||||
|
ResourceType = "Feature",
|
||||||
|
ResourceId = "1",
|
||||||
|
Action = "Deleted",
|
||||||
|
OrganizationId = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.That(log.Changes, Is.Null);
|
||||||
|
Assert.That(log.ProjectId, Is.Null);
|
||||||
|
Assert.That(log.EnvironmentId, Is.Null);
|
||||||
|
Assert.That(log.ActorUserId, Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs
Normal file
75
tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Moq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using MicCheck.Api.Auth;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Tests.Unit.Auth;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class AuthEndpointsTests
|
||||||
|
{
|
||||||
|
private WebApplicationFactory<Program> _factory = null!;
|
||||||
|
private Mock<ITokenService> _tokenService = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_tokenService = new Mock<ITokenService>();
|
||||||
|
|
||||||
|
_factory = new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(host =>
|
||||||
|
{
|
||||||
|
host.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddScoped<ITokenService>(_ => _tokenService.Object);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown() => _factory.Dispose();
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenUsernameIsEmpty_ThenBadRequestIsReturned()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/auth/token",
|
||||||
|
new TokenRequest("", "password123"));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenPasswordIsEmpty_ThenBadRequestIsReturned()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/auth/token",
|
||||||
|
new TokenRequest("testuser", ""));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task WhenValidCredentialsAreProvided_ThenOkIsReturnedWithToken()
|
||||||
|
{
|
||||||
|
var expectedResponse = new TokenResponse("signed.jwt.token", DateTime.UtcNow.AddHours(1));
|
||||||
|
_tokenService
|
||||||
|
.Setup(s => s.GenerateToken("testuser"))
|
||||||
|
.Returns(expectedResponse);
|
||||||
|
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/auth/token",
|
||||||
|
new TokenRequest("testuser", "password123"));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<TokenResponse>();
|
||||||
|
Assert.That(body?.Token, Is.EqualTo(expectedResponse.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs
Normal file
67
tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using MicCheck.Api.Auth;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Tests.Unit.Auth;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TokenServiceTests
|
||||||
|
{
|
||||||
|
private IConfiguration _configuration = null!;
|
||||||
|
private TokenService _tokenService = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Jwt:SecretKey"] = "test-secret-key-that-is-long-enough-32c",
|
||||||
|
["Jwt:Issuer"] = "MicCheck",
|
||||||
|
["Jwt:Audience"] = "MicCheck",
|
||||||
|
["Jwt:ExpiryMinutes"] = "60",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_tokenService = new TokenService(_configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenAUsernameIsProvided_ThenATokenIsReturned()
|
||||||
|
{
|
||||||
|
var result = _tokenService.GenerateToken("testuser");
|
||||||
|
|
||||||
|
Assert.That(result.Token, Is.Not.Null.And.Not.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenATokenIsGenerated_ThenItContainsTheUsernameAsSubjectClaim()
|
||||||
|
{
|
||||||
|
var result = _tokenService.GenerateToken("testuser");
|
||||||
|
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwt = handler.ReadJwtToken(result.Token);
|
||||||
|
|
||||||
|
Assert.That(jwt.Subject, Is.EqualTo("testuser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenATokenIsGenerated_ThenItHasAFutureExpiry()
|
||||||
|
{
|
||||||
|
var result = _tokenService.GenerateToken("testuser");
|
||||||
|
|
||||||
|
Assert.That(result.ExpiresAt, Is.GreaterThan(DateTime.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void WhenATokenIsGenerated_ThenItHasTheConfiguredIssuer()
|
||||||
|
{
|
||||||
|
var result = _tokenService.GenerateToken("testuser");
|
||||||
|
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwt = handler.ReadJwtToken(result.Token);
|
||||||
|
|
||||||
|
Assert.That(jwt.Issuer, Is.EqualTo("MicCheck"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user