diff options
| author | Leitwolf11 | 2026-08-28 23:03:57 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-08-28 23:03:57 +0300 |
| commit | 18da54ce240b1bfefce36aa2ec3b44b58bd0dbde (patch) | |
| tree | ce146c2ca1d768f3f6418643b8ba3e6d8abce4df | |
| download | progmaweb-18da54ce240b1bfefce36aa2ec3b44b58bd0dbde.tar.gz | |
Initialize Progmaweb 1.0.0 corporate and account platform: add a responsive Next.js corporate site without Visual X# compiler-pipeline content, host-aware account routes, registration and dashboard UX, an ASP.NET Core authentication API with account policies, password hashing, secure sessions, rate limiting and security headers, integration tests on Microsoft Testing Platform, locked dependency graphs, reproducible local orchestration, and separate frontend, backend and security workflows.
59 files changed, 7927 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..efde8f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{cs,csproj,slnx}] +end_of_line = crlf +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5dbf317 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Next.js server-side proxy target. Keep this address private in production. +PROGMAWEB_API_ORIGIN=http://127.0.0.1:5085 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a7a84ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto +*.cs text eol=crlf +*.csproj text eol=crlf +*.slnx text eol=crlf +*.ts text eol=lf +*.tsx text eol=lf +*.css text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf + diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..1e63687 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,41 @@ +name: Backend + +on: + push: + branches: [main] + paths: + - "apps/api/**" + - "apps/api-tests/**" + - "Directory.Build.props" + - "Progmaweb.slnx" + - ".github/workflows/backend.yml" + pull_request: + paths: + - "apps/api/**" + - "apps/api-tests/**" + - "Directory.Build.props" + - "Progmaweb.slnx" + - ".github/workflows/backend.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: backend-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: "**/*.csproj" + - run: dotnet restore Progmaweb.slnx --locked-mode + - run: dotnet build Progmaweb.slnx --configuration Release --no-restore + - run: dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release --no-build diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..d33ec43 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,45 @@ +name: Frontend + +on: + push: + branches: [main] + paths: + - "apps/web/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/frontend.yml" + pull_request: + paths: + - "apps/web/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/frontend.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frontend-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + with: + version: 11.24.0 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - run: pnpm lint + - run: pnpm build diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..7268506 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,41 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/dependency-review-action@v4 + + dependency-audit: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + with: + version: 11.24.0 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --audit-level high diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f718505 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +.next/ +out/ +coverage/ +dist/ +bin/ +obj/ +.vs/ +.idea/ +*.user +*.suo +*.log +*.tsbuildinfo +.env +.env.* +!.env.example diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9e19e3f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,13 @@ +<Project> + <PropertyGroup> + <AnalysisLevel>latest-recommended</AnalysisLevel> + <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild> + <Deterministic>true</Deterministic> + <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> + <ImplicitUsings>enable</ImplicitUsings> + <InvariantGlobalization>false</InvariantGlobalization> + <Nullable>enable</Nullable> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> +</Project> + diff --git a/Progmaweb.slnx b/Progmaweb.slnx new file mode 100644 index 0000000..366e3b2 --- /dev/null +++ b/Progmaweb.slnx @@ -0,0 +1,7 @@ +<Solution> + <Folder Name="/apps/"> + <Project Path="apps/api/Progmaweb.Api.csproj" /> + <Project Path="apps/api-tests/Progmaweb.Api.Tests.csproj" /> + </Folder> +</Solution> + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b82bcce --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Progmaweb + +Progmaweb is the public web and account surface for Progmasoft. The repository intentionally keeps presentation and +account policy in one deployable workspace while preserving a strict process boundary between the Next.js frontend and +the ASP.NET Core API. + +## Public hosts + +- `www.progmasoft.com` serves the corporate homepage. +- `account.progmasoft.com` serves account discovery and authentication. +- `account.progmasoft.com/login` signs an existing account in. +- `account.progmasoft.com/register` creates a new account after server-side validation. +- `account.progmasoft.com/<AccountName>/dashboard` serves the authenticated account dashboard. + +Account names preserve their original display case but reserve names case-insensitively, preventing visually confusing +duplicates. Email uniqueness is also evaluated case-insensitively by the API. + +## Architecture + +- `apps/web` is a Next.js App Router application. Host-aware routing keeps the corporate and account surfaces in one + build without coupling their page hierarchy. +- `apps/api` is an ASP.NET Core minimal API. It owns account-name policy, password hashing, session issuance, secure + cookies, rate limiting, and authorization. +- The browser never receives password hashes, session digests, deployment secrets, or database credentials. +- Production account maintenance is an explicit operations workflow. The public API does not contain bulk-delete, + reset-all, seed-password, or environment-password endpoints. + +## Requirements + +- Node.js 24 or newer +- pnpm 11 or newer +- .NET SDK 10 + +The initial Progmaweb release, Git tag, and GitHub Release use version `1.0.0`. Visual X# compiler versions belong to +the language repositories and do not determine this website's version. + +## Development + +Install frontend dependencies and start Next.js: + +```text +pnpm install --frozen-lockfile +pnpm dev +``` + +Start the account API in another terminal: + +```text +dotnet run --project apps/api/Progmaweb.Api.csproj +``` + +The frontend defaults to `http://localhost:5085` for server-side API requests. Set `PROGMAWEB_API_ORIGIN` when the API +uses another origin. Browser requests use the same-origin `/api` boundary so production can proxy them without exposing +an internal address. + +## Verification + +```text +pnpm check +pnpm build +dotnet build apps/api/Progmaweb.Api.csproj --configuration Release +dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release +``` diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs new file mode 100644 index 0000000..fb21f60 --- /dev/null +++ b/apps/api-tests/AccountApiFactory.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace Progmasoft.Progmaweb.Api.Tests; + +internal sealed class AccountApiFactory : WebApplicationFactory<Program> +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary<string, string?> + { + ["AccountSessions:LifetimeHours"] = "2", + ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession" + }); + }); + } + + public HttpClient CreateAccountClient(bool handleCookies = true) => CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("https://localhost"), + HandleCookies = handleCookies + }); +} diff --git a/apps/api-tests/AccountEndpointTests.cs b/apps/api-tests/AccountEndpointTests.cs new file mode 100644 index 0000000..b868ccd --- /dev/null +++ b/apps/api-tests/AccountEndpointTests.cs @@ -0,0 +1,180 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Progmasoft.Progmaweb.Api.Tests; + +public sealed class AccountEndpointTests +{ + [Fact] + public async Task RegisterCreatesSessionAndMeReturnsAccount() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + + HttpResponseMessage registration = await client.PostAsJsonAsync("/api/v1/accounts/register", new + { + accountName = "PrimaryUser", + email = "leitwolf1112@gmail.com", + password = "a-long-development-password" + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Created, registration.StatusCode); + Assert.True(registration.Headers.TryGetValues("Set-Cookie", out IEnumerable<string>? cookies)); + string sessionCookie = Assert.Single(cookies); + Assert.Contains("__Host-ProgmasoftSession=", sessionCookie, StringComparison.Ordinal); + Assert.Contains("secure", sessionCookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("httponly", sessionCookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("samesite=lax", sessionCookie, StringComparison.OrdinalIgnoreCase); + + HttpResponseMessage me = + await client.GetAsync("/api/v1/accounts/me", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, me.StatusCode); + using JsonDocument body = + JsonDocument.Parse(await me.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal("PrimaryUser", body.RootElement.GetProperty("accountName").GetString()); + Assert.Equal("leitwolf1112@gmail.com", body.RootElement.GetProperty("email").GetString()); + } + + [Fact] + public async Task LogoutRevokesCurrentSession() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + + HttpResponseMessage registration = await client.PostAsJsonAsync("/api/v1/accounts/register", new + { + accountName = "LogoutUser", + email = "logout@example.com", + password = "a-long-development-password" + }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, registration.StatusCode); + + Assert.Equal(HttpStatusCode.NoContent, + (await client.PostAsync("/api/v1/accounts/logout", null, TestContext.Current.CancellationToken)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, + (await client.GetAsync("/api/v1/accounts/me", TestContext.Current.CancellationToken)).StatusCode); + } + + [Theory] + [InlineData("lowercase")] + [InlineData("Short7")] + [InlineData("Contains-Dash")] + [InlineData("ĞecersizAd")] + [InlineData("Progmasoft")] + [InlineData("Leitwolf")] + public async Task RegisterRejectsInvalidOrReservedAccountName(string accountName) + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + + HttpResponseMessage response = await client.PostAsJsonAsync("/api/v1/accounts/register", new + { + accountName, + email = $"{Guid.NewGuid():N}@example.com", + password = "a-long-development-password" + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("accountName", body, StringComparison.Ordinal); + } + + [Fact] + public async Task EmailUniquenessIsCaseInsensitive() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + + HttpResponseMessage first = await RegisterAsync(client, "SampleUser", "Person@Example.com"); + HttpResponseMessage second = await RegisterAsync(client, "DifferentUser", "person@example.COM"); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Contains("email", await second.Content.ReadAsStringAsync(TestContext.Current.CancellationToken), + StringComparison.Ordinal); + } + + [Fact] + public async Task AccountNameUniquenessIsCaseInsensitiveWhileDisplayCaseIsPreserved() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + + HttpResponseMessage first = await RegisterAsync(client, "SampleUser", "first-name@example.com"); + HttpResponseMessage second = await RegisterAsync(client, "SampleUSER", "second-name@example.com"); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Contains("accountName", await second.Content.ReadAsStringAsync(TestContext.Current.CancellationToken), + StringComparison.Ordinal); + } + + [Fact] + public async Task DuplicateAccountNameIsRejectedWithoutChangingCase() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + + HttpResponseMessage first = await RegisterAsync(client, "UniqueName", "first@example.com"); + HttpResponseMessage second = await RegisterAsync(client, "UniqueName", "second@example.com"); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Contains("accountName", await second.Content.ReadAsStringAsync(TestContext.Current.CancellationToken), + StringComparison.Ordinal); + } + + [Fact] + public async Task LoginUsesGenericFailureForUnknownEmailAndWrongPassword() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(); + await RegisterAsync(client, "LoginUser", "login@example.com"); + await client.PostAsync("/api/v1/accounts/logout", null, TestContext.Current.CancellationToken); + + HttpResponseMessage missing = await client.PostAsJsonAsync("/api/v1/accounts/login", new + { + email = "missing@example.com", + password = "a-long-development-password" + }, TestContext.Current.CancellationToken); + HttpResponseMessage wrong = await client.PostAsJsonAsync("/api/v1/accounts/login", new + { + email = "login@example.com", + password = "a-long-but-incorrect-password" + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); + using JsonDocument missingBody = + JsonDocument.Parse(await missing.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + using JsonDocument wrongBody = + JsonDocument.Parse(await wrong.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(missingBody.RootElement.GetProperty("title").GetString(), + wrongBody.RootElement.GetProperty("title").GetString()); + Assert.Equal(missingBody.RootElement.GetProperty("detail").GetString(), + wrongBody.RootElement.GetProperty("detail").GetString()); + } + + [Fact] + public async Task StatusResponseAdvertisesProgmawebVersion() + { + await using AccountApiFactory factory = new(); + using HttpClient client = factory.CreateAccountClient(handleCookies: false); + + using JsonDocument body = + JsonDocument.Parse(await client.GetStringAsync("/api/v1/status", TestContext.Current.CancellationToken)); + + Assert.Equal("progmaweb-account", body.RootElement.GetProperty("service").GetString()); + Assert.Equal("online", body.RootElement.GetProperty("status").GetString()); + Assert.Equal("1.0.0", body.RootElement.GetProperty("version").GetString()); + } + + private static Task<HttpResponseMessage> RegisterAsync(HttpClient client, string accountName, string email) => + client.PostAsJsonAsync("/api/v1/accounts/register", new + { + accountName, + email, + password = "a-long-development-password" + }, TestContext.Current.CancellationToken); +} diff --git a/apps/api-tests/GlobalUsings.cs b/apps/api-tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/apps/api-tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/apps/api-tests/Progmaweb.Api.Tests.csproj b/apps/api-tests/Progmaweb.Api.Tests.csproj new file mode 100644 index 0000000..7c7202f --- /dev/null +++ b/apps/api-tests/Progmaweb.Api.Tests.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Progmasoft.Progmaweb.Api.Tests</RootNamespace> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" |