diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d435158a..7f14a5a6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: version: 10 - uses: actions/setup-go@v6 with: - go-version: 1.26.3 + go-version: 1.26.4 - name: Install nfpm run: | echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 5ea9c3f8a..bfd4b2ddf 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -55,7 +55,7 @@ jobs: version: 10 - uses: actions/setup-go@v6 with: - go-version: 1.26.3 + go-version: 1.26.4 - name: Unit tests env: TEST_POSTGRES_HOST: runner.local @@ -90,7 +90,7 @@ jobs: version: 10 - uses: actions/setup-go@v6 with: - go-version: 1.26.3 + go-version: 1.26.4 - name: Install nfpm run: | echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list diff --git a/.gitignore b/.gitignore index 445e35329..18de61deb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,4 @@ build/ **/*.mock.go **/*.generated.go **/*.generated.ts -i18n/*.md coverage.out diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2156a1037 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,695 @@ +# nginx-ignition Guide for AI Agents + +Project-wide architecture, conventions, and expected behaviour for nginx-ignition. Follow these patterns when adding +features, fixing bugs, or writing tests in any module. + +Domain-specific supplements (e.g. `core/notification/AGENTS.md`) extend this document — they do not replace it. + +**When i18n is in scope** — adding or modifying message keys, `.properties` files, or user-facing text in Go or +frontend — agents **must** also read and follow [`i18n/AGENTS.md`](i18n/AGENTS.md). + +## Architecture + +### Module layout + +The repository is a Go workspace (`go.work`) with separate modules per layer and feature area: + +| Layer | Path | Role | +|---------------------|---------------------------------------------------------|------------------------------------------------------| +| Core business logic | `core/` | Services, validators, domain models, scheduled tasks | +| Persistence | `database/` | Repositories, DB models, converters | +| HTTP API | `api/` | Gin handlers, DTOs, route registration | +| Feature plugins | `certificate/`, `integration/`, `vpn/`, `notification/` | Driver/provider implementations in sibling modules | +| i18n | `i18n/` | Properties files and generated message keys | +| Frontend UI | `frontend/` | React SPA: domain pages, services, shared components | +| Application | `application/` | Composition root, server startup | + +Each domain (user, host, vpn, nginx, notification, …) has matching packages across `core/`, `database/`, and `api/` when +it exposes HTTP or persistence. + +### Core package file roles + +| File | Role | +|-----------------------------|---------------------------------------------------------------------------------------------------------| +| `service.go` | Struct definition, constructor, shared helpers | +| `service_{scope}.go` | Scoped service methods (see below) | +| `commands.go` | `Commands` interface consumed by other layers; catalog DTOs (`AvailableDriver`, `AvailableProvider`, …) | +| `model.go` | Domain entities, enums, domain helper functions | +| `constants.go` | Sentinel errors, limits, magic values | +| `validator.go` | Business validation via `validation.ConsistencyValidator` | +| `repository.go` | Repository interface | +| `provider.go` / `driver.go` | External integration interface (when applicable) | +| `installer.go` | DI wiring via `container.Provide` / `container.Run` | +| `{name}_task.go` | Scheduled task registration and `Run`/`Schedule` (delegates to `Commands`) | + +### Service decomposition + +When a service grows beyond a handful of methods, split it across scoped files — **never** consolidate everything into a +monolithic `service.go`: + +| Pattern | Example | +|---------------------|----------------------------------------------------------------------| +| Single-scope module | `core/user/service.go` — all methods in one file when small | +| Multi-scope module | `core/nginx/service.go` + `service_stats.go` + `service_metadata.go` | +| Multi-scope module | `core/notification/service_inbox.go` + `service_publish.go` + … | + +Each scoped source file gets a matching scoped test file (see Test Organization). + +### Commands interface + +`commands.go` defines the public contract for a domain. Other modules depend on `Commands`, not on the concrete +`service` struct. + +Catalog DTOs for pluggable drivers/providers live here, mirroring `vpn.AvailableDriver`: + +```go +type AvailableDriver struct { + Name *i18n.Message + ID string + ImportantInstructions []*i18n.Message + ConfigurationFields []dynamicfields.DynamicField +} +``` + +### Validation + +Business validation belongs in `validator.go`, not in API handlers. Use `validation.ConsistencyValidator` (same pattern +as `core/vpn`, `core/user`, `integration`): + +- Collect field errors via `delegate.Add(field, message)` +- Return via `delegate.Error()` at the end +- Validate `dynamicfields` parameters when a driver/provider defines `ConfigurationFields` + +### DI / installer pattern + +Each module exposes `Install() error` in `installer.go`: + +```go +func Install() error { + return container.Provide(newCommands) +} + +func newCommands(deps ...) (*service, Commands) { + svc := newService(deps...) + return svc, svc +} +``` + +Complex modules chain sub-installers and startup hooks: + +```go +container.Run(registerStartup, registerScheduledTask, registerShutdown) +``` + +The `database/installer.go` registers repositories; `core/installer.go` and `api/installer.go` compose all domain +installers. + +### Scheduled tasks + +Follow `core/nginx/log_rotation_task.go`: + +| File | Responsibility | +|-----------------------|--------------------------------------------------------------| +| `{name}_task.go` | Scheduler registration, `Run`, `Schedule`, interval constant | +| `{name}_task_test.go` | Tests for schedule interval, `Run` wiring, registration | + +The task delegates to `Commands` — it does not contain business logic itself. + +## Test Organization + +Violations of these rules cause significant review friction. + +### Scoped test files + +Test files mirror their source files: + +| Source | Test | +|-----------------------------|----------------------------------------------------| +| `service.go` (single-scope) | `service_test.go` → `Test_service` | +| `service_{scope}.go` | `service_{scope}_test.go` → `Test_service_{scope}` | +| `validator.go` | `validator_test.go` | +| `{name}_task.go` | `{name}_task_test.go` → `Test_{name}Task` | +| `model.go` (pure helpers) | `model_test.go` when needed | +| `converter.go` | `converter_test.go` | + +### Forbidden patterns + +| Forbidden | Why | +|----------------------------------------------------------------|--------------------------------------------------------------| +| `service_test.go` when scoped `service_{scope}.go` files exist | Monolithic — use scoped test files | +| Orphan test files without matching source | e.g. `delivery_test.go` when source is `service_delivery.go` | +| Merging scoped tests into one file | Each scope gets its own test file | +| Assertion-bearing tests in `artifacts_test.go` | Helpers only | + +### Nested test structure + +Three levels — reference `core/user/service_test.go`: + +``` +Test_service_{scope}(t) // or Test_service, Test_logRotationTask, Test_Repository + └── t.Run("{MethodName}", ...) + └── t.Run("{scenario description}", ...) + └── ctrl := gomock.NewController(t) // controller HERE, in leaf subtest +``` + +Rules: + +- One `gomock.NewController` per **leaf** scenario subtest — never share across siblings. +- Top-level name matches the source scope (`Test_service`, `Test_service_inbox`, `Test_listHandler`). +- Second level: method name (`Get`, `Save`, `handle`, `Schedule`). +- Third level: behavioural scenario in plain English. + +### Test helpers (`artifacts_test.go`) + +Reusable fixtures only — no test functions that assert behaviour: + +- `core/user/artifacts_test.go` — `newUser()`, `newSaveRequest()` +- `api/user/artifacts_test.go` — `newUserPage()`, sample DTOs +- `database/user/artifacts_test.go` — `newUser()` for repository tests + +### Database repository tests + +``` +Test_Repository(t) + └── testutils.RunWithMockedDatabases(t, runRepositoryTests) + +runRepositoryTests(t, db) + └── t.Run("{MethodName}", ...) + └── t.Run("{scenario}", ...) +``` + +Use helpers from `database/{domain}/artifacts_test.go`. Both SQLite and PostgreSQL are exercised via +`testutils.RunWithMockedDatabases`. + +### API handler tests + +Structure: `Test_{handlerName}(t)` → `t.Run("handle", ...)` → scenario subtests. + +Set the authenticated subject via middleware: + +```go +engine.Use(func (ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: subject}) + ginContext.Next() +}) +``` + +One gomock controller per leaf scenario, same as service tests. + +## API Conventions + +Handlers live in `api/{domain}/`. They are thin — validation and business rules belong in `core/{domain}/validator.go` +and service methods. + +### Authentication and authorization + +- Read the current user via `authorization.CurrentSubject(ctx)` (see `api/common/authorization/subject.go`). +- Register routes with `authorizer.AllowAllUsers(...)` when any authenticated user may access their own scoped data; + enforce ownership by passing `userID` to core commands. +- Admin-only routes use permission-based authorization instead. + +### List endpoints — pagination only + +List handlers accept **only** standard pagination parameters via `pagination.ExtractPaginationParameters(ctx)`: + +- `pageSize` +- `pageNumber` +- `searchTerms` + +This matches `api/user`, `api/host`, `api/cache`, and other list handlers. + +**Do not** add domain-specific filter query parameters to public list APIs unless explicitly required. Filtering beyond +search belongs in core/repository if ever needed. + +### Route ordering — static before `/:id` + +Register static path segments **before** parameterized routes to avoid Gin shadowing: + +```go +group.GET("/available-providers", ...) // static first +group.GET("/unread-count", ...) +group.GET("", ...) // list + +byIDPath := group.Group("/:id") +byIDPath.GET("", ...) +byIDPath.PUT("", ...) +byIDPath.DELETE("", ...) +``` + +### Thin handlers + +Handlers bind JSON, convert DTOs, call `Commands`, return responses. They do **not**: + +- Run business validation (core validator handles this) +- Contain i18n resolution logic +- Merge sensitive fields (service layer handles this) + +Converters deserialize JSON to domain types; invalid values are caught by the core validator. + +### Sensitive configuration fields + +On read, strip sensitive provider/driver parameters via `dynamicfields.RemoveSensitiveFields` in the service layer. On +save, merge without overwriting unchanged secrets: + +```go +configuration.Parameters = dynamicfields.MergeSensitiveFields( + configuration.Parameters, + existing.Parameters, + provider.ConfigurationFields(ctx), +) +``` + +Always assign the return value — `MergeSensitiveFields` returns the merged map. + +## Frontend + +The UI is a React 19 + TypeScript SPA in `frontend/`, built with Vite and Ant Design. Domain screens live under +`frontend/src/domain/`; shared infrastructure under `frontend/src/core/`. + +**When i18n is in scope** — read [`i18n/AGENTS.md`](i18n/AGENTS.md) for key naming (`frontend/{domain}/` prefix), +generation workflow, and `` usage. + +### Module layout + +| Path | Role | +|----------------------------------|-------------------------------------------------------------| +| `frontend/src/domain/{domain}/` | List/form pages, domain services, gateways, models, actions | +| `frontend/src/core/components/` | Reusable UI (DataTable, shell, forms, access control, …) | +| `frontend/src/core/apiclient/` | `ApiClient` (fetch), response helpers | +| `frontend/src/core/i18n/` | `I18n` component, context, generated `MessageKey` | +| `frontend/src/domain/Routes.tsx` | Route + sidebar menu registration | + +Each domain with a management UI typically includes: + +| File | Role | +|----------------------|-----------------------------------------| +| `{Name}ListPage.tsx` | Paginated list via `DataTable` | +| `{Name}FormPage.tsx` | Create (`/new`) / edit (`/:id`) form | +| `{Name}Gateway.ts` | HTTP calls to `/api/{name}` | +| `{Name}Service.ts` | Response unwrapping, orchestration | +| `model/*.ts` | TypeScript interfaces matching API JSON | +| `actions/*Action.ts` | Confirm → API → toast flows | + +### React conventions + +The codebase uses **class components** — not hooks. + +| Pattern | Example | +|--------------------|---------------------------------------------------------| +| List page | `React.PureComponent` + `DataTable` ref | +| Form page | `React.Component` + `formRef` + `this.state.formValues` | +| Destructive action | Singleton class exported as `new DeleteXAction()` | + +```tsx +// ✅ CORRECT — shell title/actions in componentDidMount +componentDidMount() { + AppShellContext.get().updateConfig({ + title: MessageKey.CommonUsers, + actions: [{ description: MessageKey.FrontendUserNewButton, onClick: "/users/new" }], + }) +} + +// ❌ DO NOT USE, EVER! — functional components with hooks (not used in this project) +export default function UserListPage() { + const [data, setData] = useState(...) +} +``` + +### API integration + +Gateway → Service → Page. No axios; no generated OpenAPI client. + +```typescript +// Gateway — query params only for list pagination/search +async getPage(pageSize ? : number, pageNumber ? : number, searchTerms ? : string) { + return this.client.get(undefined, undefined, { pageSize, pageNumber, searchTerms }) +} + +// Service — unwrap payloads +async list(...args) { + return this.gateway.getPage(...args).then(requireSuccessPayload) +} +``` + +- Base path per domain: `new ApiClient("/api/users")`. +- `ApiClient` sends `Accept-Language` from `I18nContext`. +- Use `requireSuccessPayload`, `requireNullablePayload` (404), `requireSuccessResponse` consistently. + +### List pages — pagination and search only + +List UIs must mirror backend list API conventions (see **List endpoints — pagination only** above): + +- `pageSize`, `pageNumber`, `searchTerms` — **no extra filter query params** on the frontend. +- Use `DataTable` with a stable `id` (persists user preferences per table). +- Pass `dataProvider={(pageSize, pageNumber, searchTerms) => service.list(...)}`. + +```tsx +// ✅ CORRECT + + this.service.list(pageSize, pageNumber, searchTerms) + } + rowKey={item => item.id} +/> + +// ❌ AVOID — domain-specific filters on list fetch +dataProvider = { (pageSize, pageNumber, enabledOnly) => ... } +``` + +Search is debounced in `DataTableHeader`; changing search resets to page 0. + +### Routing and menu + +Register routes in `frontend/src/domain/Routes.tsx`: + +- **Static paths before parameterized routes** (same rule as Gin). +- `menuItem` → sidebar entry; `activeMenuItemPath` for form routes under a list. +- `requiresAuthentication: false` for `/login`, `/onboarding`; `fullPage: true` for pages without shell. + +```typescript +// Static before :id +{ + path: "/certificates/new", + activeMenuItemPath: "/certificates", + ... +}, +{ + path: "/certificates/:id", + activeMenuItemPath : "/certificates", + ... +}, +{ + path: "/certificates", + menuItem: { ... }, + ... +}, +``` + +Navigate imperatively: `navigateTo("/hosts/new")`, read params: `routeParams().id`, query: `queryParams()`. + +### Forms and validation + +- Ant Design `Form` with `FormLayout.FormDefaults` / `FormLayout.LabeledItem`. +- Hold editable state in `this.state.formValues`; sync via `onValuesChange`. +- On save error, parse `consistencyProblems` from API body: + +```typescript +if (error instanceof UnexpectedResponseError) { + const validationResult = ValidationResultConverter.parse(error.response) + if (validationResult != null) this.setState({ validationResult }) +} +``` + +- Per field: `validateStatus={validationResult.getStatus("name")}` and `help={validationResult.getMessage("name")}`. +- Driver/provider config: render `DynamicInput` for each `configurationFields` entry; merge `parameters` on change. +- Sensitive fields: backend strips on read; frontend sends full form — backend merges secrets (same as API conventions). + +### Access control + +- Wrap list pages in ``. +- Gate write actions with `isAccessGranted(READ_WRITE, ...)` → disable shell buttons or show `AccessDeniedModal`. +- Form pages without list wrapper: return `` when read access missing. + +### Styling + +- Co-located **plain `.css`** files imported in the component — **not** CSS modules. +- Global variables in `frontend/src/index.css`; theme toggle sets `data-theme` on ``. +- Use `themedColors()` for semantic icon/button colors. +- Prefer Ant Design layout primitives (`Flex`, `Form`, `Table`) before custom CSS. + +### i18n (frontend) + +See [`i18n/AGENTS.md`](i18n/AGENTS.md) for keys and generation. Quick rules: + +- Import `MessageKey` from `core/i18n/model/MessageKey.generated`. +- Prefer `` in JSX. +- Use `i18n()` only when a plain string is required (input placeholder, dynamic confirmation text). +- **Never** pass raw key path strings — always use generated `MessageKey.*` constants. +- Key prefix: `frontend/src/domain/user/` → `frontend/user/...`. + +```tsx +// ✅ CORRECT + +const text = i18n(MessageKey.FrontendUserNewButton) + +// ❌ WRONG — raw key strings are forbidden + +const text = i18n("frontend/user/new-button") +``` + +### DTO / types + +- Define request/response interfaces in `domain/{name}/model/` matching `api/{name}` JSON field names. +- `PageResponse` shape: `pageSize`, `pageNumber`, `totalItems`, `contents`. +- Complex forms may use separate form types + converters (e.g. `HostFormValues` ↔ `HostRequest` via `HostConverter`). + +### Build, lint, format + +```bash +cd frontend && pnpm install +pnpm run start # dev server :8080, proxies /api → :8090 +pnpm run build # tsc && vite build → frontend/build/ +pnpm run check # prettier --check + eslint +``` + +From repo root: `make .frontend-lint`, `make .frontend-format`, `make .frontend-build` (build requires +`make .generate-i18n-files`). + +### Testing + +There is no frontend unit/component test suite today. Validate via `pnpm run check` and manual testing. Do not add +Vitest unless the project adopts it repo-wide. + +### Forbidden patterns + +| Forbidden | Why | +|--------------------------------|--------------------------------------------------------| +| Hooks in new pages | Project uses class components throughout | +| Extra list filter query params | Must match backend list API contract | +| Pre-translated user strings | Use `MessageKey` + `` | +| Raw i18n key path strings | Use `MessageKey.*` — never `"frontend/..."` literals | +| CSS modules | Project uses plain co-located CSS | +| Business validation only in UI | Server returns `consistencyProblems`; UI displays them | + +## Go Code Style (Go 1.26) + +### Use `new(expr)` for pointer fields + +```go +// ✅ CORRECT +submission.LastAttemptAt = new(time.Now()) +request.Password = new("password123") + +// ❌ AVOID +now := time.Now() +submission.LastAttemptAt = &now +``` + +### Merge error and nil checks + +```go +// ✅ CORRECT +user, err := s.repository.FindByID(ctx, id) +if err != nil || user == nil { + return nil, err +} +``` + +### General rules + +- No unnecessary comments — code should be self-explanatory +- Fix lint issues; do not dismiss them as pre-existing +- Match surrounding naming: full words, no abbreviated variables (`user`, not `u`) +- Keep diffs minimal and scoped to the task + +## i18n + +**When i18n is in scope, read [`i18n/AGENTS.md`](i18n/AGENTS.md) first.** That guide is authoritative for: + +- **Key naming** — path prefix matches code location (`core/user/` → `core/user/not-found`); `common/` for keys shared + across folders +- **Properties format** — `key/path/suffix=Value`, `/` separators only (no dots), one key per line +- **Add-key workflow** — edit `messages_en.properties`, then run `make .generate-i18n-files` to regenerate + `keys.generated.go`, `en.generated.go`, and `MessageKey.generated.ts` +- **Value rules** — `${variable}` placeholders, sentence case, unique keys and values +- **Localization** — keep all locale files in sync with `messages_en.properties` +- **Usage in code** — `i18n.M(ctx, i18n.K....)` in Go; `I18n` component / `MessageKey` in frontend + +Quick rules: + +- Key prefix must match the folder where the key is used (`core/user/` → `core/user/not-found`) +- Run `make .generate-i18n-files` after adding keys +- Use `i18n.M(ctx, i18n.K....)` in Go; use the `I18n` component / `MessageKey.*` in frontend +- **Never** pass raw key path strings — always use generated constants (`i18n.K.*` in Go, `MessageKey.*` in frontend) + +```go +// ✅ CORRECT — Go +i18n.M(ctx, i18n.K.CoreUserNotFound) +i18n.DetachedMessage{Key: i18n.K.CoreNotificationCategoryCertificateRenewed} + +// ❌ WRONG — Go raw key strings are forbidden +i18n.M(ctx, "core/user/not-found") +i18n.DetachedMessage{Key: "core/notification/category/certificate-renewed"} +``` + +```tsx +// ✅ CORRECT — frontend + +const text = i18n(MessageKey.FrontendUserNewButton) + +// ❌ WRONG — frontend raw key strings are forbidden + +const text = i18n("frontend/user/new-button") +``` + +Use `i18n.Static("...")` only for non-localized text (test fixtures, dynamic user input) — never for keys +defined in `.properties` files. + +### DetachedMessage at async boundaries + +When a producer cannot resolve the recipient's language at call time (notifications, async events), pass +`i18n.DetachedMessage` values — not pre-translated strings. Resolution happens later per recipient context. + +## Database + +### Migrations + +- Scripts live in `database/common/migrations/scripts/postgres/` and `database/common/migrations/scripts/sqlite/` +- Numbered sequentially: `NNN_description.up.sql` +- **Both** postgres and sqlite variants are required for every migration +- Keep schema changes in sync across dialects + +### Repository layer + +Each domain has `database/{domain}/`: + +| File | Role | +|-----------------|---------------------------------------------| +| `model.go` | DB row structs (may differ from core model) | +| `converter.go` | Core ↔ DB conversion | +| `repository.go` | Implements core `Repository` interface | + +Repository tests use `testutils.RunWithMockedDatabases` to verify behaviour against both SQLite and PostgreSQL. + +## Changelog & versioning + +The project changelog lives at [`CHANGELOG.md`](CHANGELOG.md) at the repository root. Every feature branch **must** +update it before merge. + +### Semantic versioning + +nginx-ignition follows [semantic versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`): + +| Segment | Bump when… | +|-----------|----------------------------------------------------------------------------------------------------------------------------| +| **MAJOR** | Breaking changes for users — removed features, incompatible config or API behaviour, migrations that require manual action | +| **MINOR** | New user-facing features or meaningful capability additions, backward compatible | +| **PATCH** | Bug fixes, security patches, dependency updates, and other backward-compatible corrections with no new capability | + +When unsure between MINOR and PATCH, ask whether a user would notice a new capability — if yes, MINOR. + +### CHANGELOG format + +Match the existing structure in [`CHANGELOG.md`](CHANGELOG.md): + +```markdown +## 2.42.0 + +- Feature headline — what users gain + - Sub-capability or detail + - Another sub-capability +- Minor fixes and improvements +``` + +Rules: + +- Add a new `## X.Y.Z` block at the **top** of the file (below the `# CHANGELOG` title) for the release the branch + targets. +- Use a top-level bullet per major feature; indent sub-bullets (two spaces) for related capabilities within that + feature. +- One cohesive story per feature — if work spans multiple PRs or deliveries (backend, providers, UI), describe the + **complete user-facing feature** in a single entry and list all planned capabilities there (see notifications in + 2.42.0). +- Avoid duplicate bullets for the same capability (e.g. mention webhooks once). +- Bug fixes and small improvements can be a single top-level bullet (`Minor fixes and improvements`) or grouped with + the feature they relate to. + +### User-facing language + +Write for **users and operators**, not developers. Describe features and outcomes — not how they were built. + +| Write about | Avoid | +|-----------------------------------------------------|------------------------------------------------------------------------------------------------| +| What users can do | Implementation (REST APIs, schedulers, retry dispatch, i18n resolution, repository layer) | +| Channels, integrations, UI surfaces | "Backend only", "foundation", "partial", "first provider", "planned for later" | +| The full feature as shipped or as one release story | Framing current work as incomplete when the release entry should describe the whole capability | + +**Good** (from 2.42.0 — complete feature story, user outcomes): + +```markdown +- Ignition now includes alerts for important events + - Get notified when certificates are expiring, renewals succeed or fail, nginx reloads fails and many more + - Inbox to see what happened, browse history, and mark alerts as read + - Send alerts by email (SMTP, Resend, or AWS SES), SMS or other systems (AWS SNS), to Telegram, Slack, Discord, and + custom webhooks with more integrations available in future versions. + - Choose which events go to each channel and set your preferred notification language +``` + +**Good** (from 2.32.0 — headline + indented capabilities): + +```markdown +- Nginx Ignition now has integrated traffic statistics + - Real-time insights into server performance + - Metrics for request rates, response times, and bandwidth + - Traffic breakdown by host, domain, and upstream servers +``` + +**Bad** — implementation framing, incomplete story, duplicates: + +```markdown +- Added notification foundation (backend) + - REST API for notification inbox and unread count + - SMTP provider — first delivery channel; more providers planned + - Scheduled retry dispatch for failed deliveries + - DetachedMessage i18n resolution per recipient + - Webhook provider support + - Custom webhook endpoints +``` + +Prefer the 2.42.0 style: one headline, sub-bullets for inbox, channels (email/SMS/chat/webhooks in one line), and +preferences — no "foundation", no API/scheduler jargon, no duplicate webhook lines. + +## Summary Checklist + +When working on any module: + +- [ ] Business logic in `core/`, persistence in `database/`, HTTP in `api/` +- [ ] Large services split into `service_{scope}.go`; matching scoped test files +- [ ] `Commands` interface in `commands.go`; validation in `validator.go` +- [ ] Test structure: top-level scope → method → scenario; gomock controller in leaf subtest +- [ ] `artifacts_test.go` for helpers only — no assertion tests +- [ ] Database tests: `Test_Repository` → `RunWithMockedDatabases` → method → scenario +- [ ] API tests: `Test_{handler}` → `handle` → scenario; set `ABAC:Subject` in middleware +- [ ] List APIs use only `pageSize`, `pageNumber`, `searchTerms` +- [ ] Static routes registered before `/:id` groups +- [ ] Handlers thin; validation in core +- [ ] Use `new(expr)` for pointer assignment +- [ ] i18n changes → read `i18n/AGENTS.md` +- [ ] i18n keys follow folder-prefix convention (see `i18n/AGENTS.md`) +- [ ] i18n keys via `i18n.K.*` (Go) or `MessageKey.*` (frontend) — never raw strings +- [ ] Migrations in both postgres and sqlite +- [ ] Fix lint issues before finishing +- [ ] Domain UI in `frontend/src/domain/{domain}/` with Gateway + Service + model types +- [ ] List pages use `DataTable` with only `pageSize`, `pageNumber`, `searchTerms` +- [ ] Routes in `Routes.tsx`: static paths before `/:id`; `menuItem` / `activeMenuItemPath` set +- [ ] Forms use `ValidationResult` from API `consistencyProblems` +- [ ] User-facing text via `` and `MessageKey.*` — never raw key strings (see `i18n/AGENTS.md`) +- [ ] Co-located plain CSS; no CSS modules +- [ ] Class components (no hooks) unless project direction changes +- [ ] `make .generate-i18n-files` before frontend build when keys change +- [ ] `pnpm run check` / `make .frontend-lint` before finishing +- [ ] `CHANGELOG.md` updated on every feature branch before merge +- [ ] CHANGELOG entry: user-facing language, full feature story, format matches existing version blocks +- [ ] Version bump follows semver (`MAJOR.MINOR.PATCH`) when setting the release number diff --git a/CHANGELOG.md b/CHANGELOG.md index d78dd2a08..41aa6df9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 2.42.0 +- Ignition now includes alerts for important events + - Get notified when certificates are expiring, renewals succeed or fail, nginx reloads fails and many more + - Inbox to see what happened, browse history, and mark alerts as read + - Send alerts by email (SMTP, Resend, or AWS SES), SMS or other systems (AWS SNS), to Telegram, Slack, Discord, and + custom webhooks with more integrations available in future versions. + - Choose which events go to each channel and set your preferred notification language - Minor fixes and improvements ## 2.41.0 diff --git a/Makefile b/Makefile index c10852367..3d522bfb7 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ LDFLAGS := -X 'dillmann.com.br/nginx-ignition/core/common/version.Number=$(VERSI ./database/... \ ./integration/docker/... \ ./integration/truenas/... \ + ./notification/smtp/... \ ./vpn/netbird/... \ ./vpn/tailscale/... @@ -119,6 +120,7 @@ LDFLAGS := -X 'dillmann.com.br/nginx-ignition/core/common/version.Number=$(VERSI ./database/... \ ./integration/docker/... \ ./integration/truenas/... \ + ./notification/smtp/... \ ./vpn/netbird/... \ ./vpn/tailscale/... go tool golangci-lint run --fix \ @@ -133,15 +135,16 @@ LDFLAGS := -X 'dillmann.com.br/nginx-ignition/core/common/version.Number=$(VERSI ./database/... \ ./integration/docker/... \ ./integration/truenas/... \ + ./notification/smtp/... \ ./vpn/netbird/... \ ./vpn/tailscale/... clean: - @find api application certificate core database i18n integration vpn -type f -name "*.mock.go" -delete + @find api application certificate core database i18n integration notification vpn -type f -name "*.mock.go" -delete .backend-test-mocks: .backend-prerequisites @echo "Generating mock files..." - @find api application certificate core database i18n integration vpn -type f -name "*.go" \ + @find api application certificate core database i18n integration notification vpn -type f -name "*.go" \ -not -name "*_test.go" \ -exec sh -c 'grep -q "^type [a-zA-Z0-9_]* interface" "$$1" && echo "$$1"' _ {} \; | \ while read -r file; do \ @@ -176,6 +179,7 @@ clean: ./database/... \ ./integration/docker/... \ ./integration/truenas/... \ + ./notification/smtp/... \ ./vpn/netbird/... \ ./vpn/tailscale/... @@ -208,6 +212,7 @@ update-dependencies: .backend-prerequisites .frontend-prerequisites .update-ngin cd database && go get -u ./... cd integration/docker && go get -u ./... cd integration/truenas && go get -u ./... + cd notification/smtp && go get -u ./... cd tools && go get -u ./... cd vpn/netbird && go get -u ./... cd vpn/tailscale && go get -u ./... diff --git a/api/go.mod b/api/go.mod index 8720c1691..f3f132fd1 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/api -go 1.26.3 +go 1.26.4 require ( github.com/gin-gonic/gin v1.12.0 diff --git a/api/installer.go b/api/installer.go index d0d45f583..ec1624897 100644 --- a/api/installer.go +++ b/api/installer.go @@ -13,6 +13,7 @@ import ( "dillmann.com.br/nginx-ignition/api/i18n" "dillmann.com.br/nginx-ignition/api/integration" "dillmann.com.br/nginx-ignition/api/nginx" + "dillmann.com.br/nginx-ignition/api/notification" "dillmann.com.br/nginx-ignition/api/settings" "dillmann.com.br/nginx-ignition/api/stream" "dillmann.com.br/nginx-ignition/api/user" @@ -29,6 +30,7 @@ func Install() error { cache.Install, certificate.Install, user.Install, + notification.Install, host.Install, i18n.Install, integration.Install, diff --git a/api/notification/artifacts_test.go b/api/notification/artifacts_test.go new file mode 100644 index 000000000..247f617db --- /dev/null +++ b/api/notification/artifacts_test.go @@ -0,0 +1,40 @@ +package notification + +import ( + "time" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func sampleNotification(userID uuid.UUID) *notification.Notification { + notificationID := uuid.New() + + return ¬ification.Notification{ + ID: notificationID, + UserID: userID, + Title: "Certificate renewed", + Summary: "example.com was renewed", + Category: notification.CategoryCertificateRenewed, + ReadAt: new(time.Now()), + CreatedAt: time.Now(), + Payload: notification.Payload{ + OccurredAt: time.Now(), + Tags: map[string]string{"domain": "example.com"}, + Sections: []notification.DeliverableContentSection{ + {Body: "Renewal completed"}, + }, + Actions: []notification.DeliverableAction{ + {Label: "View", URL: "/certificates"}, + }, + }, + RelatedEntities: []notification.RelatedEntity{ + {Type: "certificate", ID: uuid.New(), Name: "example.com"}, + }, + Submissions: []notification.ProviderSubmission{ + {Provider: "SMTP", Status: notification.ProviderSubmissionStatusSuccess}, + }, + DeliveryCompleted: true, + } +} diff --git a/api/notification/available_providers_handler.go b/api/notification/available_providers_handler.go new file mode 100644 index 000000000..49321edd2 --- /dev/null +++ b/api/notification/available_providers_handler.go @@ -0,0 +1,27 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type availableProvidersHandler struct { + commands notification.Commands +} + +func (h availableProvidersHandler) handle(ctx *gin.Context) { + data, err := h.commands.GetAvailableProviders(ctx.Request.Context()) + if err != nil { + panic(err) + } + + payload := make([]availableProviderResponse, len(data)) + for index, item := range data { + payload[index] = toAvailableProviderDTO(item) + } + + ctx.JSON(http.StatusOK, payload) +} diff --git a/api/notification/available_providers_handler_test.go b/api/notification/available_providers_handler_test.go new file mode 100644 index 000000000..811870cdb --- /dev/null +++ b/api/notification/available_providers_handler_test.go @@ -0,0 +1,45 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func Test_availableProvidersHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with providers list on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + providers := []notification.AvailableProvider{} + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + GetAvailableProviders(gomock.Any()). + Return(providers, nil) + + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest( + http.MethodGet, + "/available-providers", + nil, + ) + + handler := availableProvidersHandler{commands: commands} + handler.handle(ginContext) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response []availableProviderResponse + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Len(t, response, 0) + }) + }) +} diff --git a/api/notification/categories_handler.go b/api/notification/categories_handler.go new file mode 100644 index 000000000..083e3e152 --- /dev/null +++ b/api/notification/categories_handler.go @@ -0,0 +1,31 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type categoriesHandler struct { + commands notification.Commands +} + +func (h categoriesHandler) handle(ctx *gin.Context) { + if _, ok := currentUserID(ctx); !ok { + return + } + + data, err := h.commands.ListCategories(ctx.Request.Context()) + if err != nil { + panic(err) + } + + payload := make([]categoryResponse, len(data)) + for index, item := range data { + payload[index] = toCategoryDTO(item) + } + + ctx.JSON(http.StatusOK, payload) +} diff --git a/api/notification/categories_handler_test.go b/api/notification/categories_handler_test.go new file mode 100644 index 000000000..57337adf2 --- /dev/null +++ b/api/notification/categories_handler_test.go @@ -0,0 +1,65 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_categoriesHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with categories on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + categories := []notification.CategoryInfo{ + {ID: notification.CategoryCertificateRenewed}, + } + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + ListCategories(gomock.Any()). + Return(categories, nil) + + handler := categoriesHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/categories", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/categories", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response []categoryResponse + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Len(t, response, 1) + assert.Equal(t, string(notification.CategoryCertificateRenewed), response[0].ID) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := categoriesHandler{commands: nil} + engine := gin.New() + engine.GET("/categories", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/categories", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + }) +} diff --git a/api/notification/converter.go b/api/notification/converter.go new file mode 100644 index 000000000..6f879c86a --- /dev/null +++ b/api/notification/converter.go @@ -0,0 +1,157 @@ +package notification + +import ( + "encoding/json" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/api/common/dynamicfield" + "dillmann.com.br/nginx-ignition/core/notification" +) + +func toNotificationDTO(data *notification.Notification) notificationResponse { + return notificationResponse{ + ID: data.ID, + Title: data.Title, + Summary: data.Summary, + Category: string(data.Category), + Read: data.ReadAt != nil, + CreatedAt: data.CreatedAt, + OccurredAt: data.Payload.OccurredAt, + Tags: data.Payload.Tags, + Sections: toContentSectionDTOs(data.Payload.Sections), + Actions: toActionDTOs(data.Payload.Actions), + RelatedEntities: toRelatedEntityDTOs(data.RelatedEntities), + } +} + +func toContentSectionDTOs( + sections []notification.DeliverableContentSection, +) []contentSectionResponse { + result := make([]contentSectionResponse, len(sections)) + for index, section := range sections { + result[index] = contentSectionResponse{ + Title: section.Title, + Body: section.Body, + } + } + + return result +} + +func toActionDTOs(actions []notification.DeliverableAction) []actionResponse { + result := make([]actionResponse, len(actions)) + for index, action := range actions { + result[index] = actionResponse{ + Label: action.Label, + URL: action.URL, + } + } + + return result +} + +func toRelatedEntityDTOs(entities []notification.RelatedEntity) []relatedEntityResponse { + result := make([]relatedEntityResponse, len(entities)) + for index, entity := range entities { + result[index] = relatedEntityResponse{ + Type: entity.Type, + ID: entity.ID, + Name: entity.Name, + } + } + + return result +} + +func toCategoryDTO(data notification.CategoryInfo) categoryResponse { + return categoryResponse{ + ID: string(data.ID), + Name: data.Name, + } +} + +func categoriesToJSON(categories *[]notification.Category) (json.RawMessage, error) { + if categories == nil { + return json.RawMessage("null"), nil + } + + values := make([]string, len(*categories)) + for index, category := range *categories { + values[index] = string(category) + } + + encoded, err := json.Marshal(values) + if err != nil { + return nil, err + } + + return encoded, nil +} + +func toConfigurationDTO(data *notification.Configuration) (configurationResponse, error) { + categories, err := categoriesToJSON(data.Categories) + if err != nil { + return configurationResponse{}, err + } + + return configurationResponse{ + ID: data.ID, + Name: data.Name, + Provider: data.Provider, + Enabled: data.Enabled, + Parameters: data.Parameters, + Categories: categories, + }, nil +} + +func parseCategories(raw json.RawMessage) (*[]notification.Category, error) { + if len(raw) == 0 { + return nil, nil + } + + if string(raw) == "null" { + return nil, nil + } + + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + + categories := make([]notification.Category, len(values)) + for index, value := range values { + categories[index] = notification.Category(value) + } + + return new(categories), nil +} + +func toConfigurationDomain( + userID, id uuid.UUID, + request *configurationRequest, +) (*notification.Configuration, error) { + categories, err := parseCategories(request.Categories) + if err != nil { + return nil, err + } + + return ¬ification.Configuration{ + ID: id, + UserID: userID, + Name: request.Name, + Provider: request.Provider, + Enabled: request.Enabled, + Parameters: request.Parameters, + Categories: categories, + }, nil +} + +func toAvailableProviderDTO(data notification.AvailableProvider) availableProviderResponse { + return availableProviderResponse{ + ID: data.ID, + Name: data.Name, + ImportantInstructions: data.ImportantInstructions, + ConfigurationFields: dynamicfield.ToResponse(data.ConfigurationFields), + } +} diff --git a/api/notification/create_configuration_handler.go b/api/notification/create_configuration_handler.go new file mode 100644 index 000000000..91002bea1 --- /dev/null +++ b/api/notification/create_configuration_handler.go @@ -0,0 +1,41 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type createConfigurationHandler struct { + commands notification.Commands +} + +func (h createConfigurationHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + payload := &configurationRequest{} + if err := ctx.BindJSON(payload); err != nil { + panic(err) + } + + domainModel, err := toConfigurationDomain(userID, uuid.New(), payload) + if err != nil { + panic(err) + } + + if err := h.commands.SaveConfiguration( + ctx.Request.Context(), + userID, + domainModel, + ); err != nil { + panic(err) + } + + ctx.JSON(http.StatusCreated, map[string]any{"id": domainModel.ID}) +} diff --git a/api/notification/create_configuration_handler_test.go b/api/notification/create_configuration_handler_test.go new file mode 100644 index 000000000..c1b0f5f92 --- /dev/null +++ b/api/notification/create_configuration_handler_test.go @@ -0,0 +1,61 @@ +package notification + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_createConfigurationHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 201 Created on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + payload := configurationRequest{ + Name: "Primary SMTP", + Provider: "SMTP", + Enabled: true, + Parameters: map[string]any{"host": "smtp.example.com"}, + Categories: json.RawMessage("null"), + } + + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + SaveConfiguration(gomock.Any(), userID, gomock.Any()). + DoAndReturn(func(_ any, _ uuid.UUID, configuration *notification.Configuration) error { + assert.Equal(t, userID, configuration.UserID) + assert.Equal(t, payload.Name, configuration.Name) + assert.Nil(t, configuration.Categories) + return nil + }) + + handler := createConfigurationHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.POST("", handler.handle) + + body, _ := json.Marshal(payload) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(body)) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusCreated, recorder.Code) + }) + }) +} diff --git a/api/notification/delete_configuration_handler.go b/api/notification/delete_configuration_handler.go new file mode 100644 index 000000000..8b9738880 --- /dev/null +++ b/api/notification/delete_configuration_handler.go @@ -0,0 +1,33 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type deleteConfigurationHandler struct { + commands notification.Commands +} + +func (h deleteConfigurationHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + + if err := h.commands.DeleteConfiguration(ctx.Request.Context(), userID, id); err != nil { + panic(err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/api/notification/delete_configuration_handler_test.go b/api/notification/delete_configuration_handler_test.go new file mode 100644 index 000000000..187130e91 --- /dev/null +++ b/api/notification/delete_configuration_handler_test.go @@ -0,0 +1,75 @@ +package notification + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_deleteConfigurationHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 204 No Content on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + configurationID := uuid.New() + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + DeleteConfiguration(gomock.Any(), userID, configurationID). + Return(nil) + + handler := deleteConfigurationHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.DELETE("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/"+configurationID.String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNoContent, recorder.Code) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := deleteConfigurationHandler{commands: nil} + engine := gin.New() + engine.DELETE("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/"+uuid.New().String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + + t.Run("returns 404 Not Found on invalid ID", func(t *testing.T) { + userID := uuid.New() + handler := deleteConfigurationHandler{commands: nil} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.DELETE("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodDelete, "/invalid", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + }) +} diff --git a/api/notification/dto.go b/api/notification/dto.go new file mode 100644 index 000000000..07ea62a2d --- /dev/null +++ b/api/notification/dto.go @@ -0,0 +1,74 @@ +package notification + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/api/common/dynamicfield" + "dillmann.com.br/nginx-ignition/core/common/i18n" +) + +type notificationResponse struct { + CreatedAt time.Time `json:"createdAt"` + OccurredAt time.Time `json:"occurredAt"` + Tags map[string]string `json:"tags"` + Title string `json:"title"` + Summary string `json:"summary"` + Category string `json:"category"` + Sections []contentSectionResponse `json:"sections"` + RelatedEntities []relatedEntityResponse `json:"relatedEntities"` + Actions []actionResponse `json:"actions"` + ID uuid.UUID `json:"id"` + Read bool `json:"read"` +} + +type contentSectionResponse struct { + Title *string `json:"title,omitempty"` + Body string `json:"body"` +} + +type actionResponse struct { + Label string `json:"label"` + URL string `json:"url"` +} + +type relatedEntityResponse struct { + Name string `json:"name,omitempty"` + Type string `json:"type"` + ID uuid.UUID `json:"id"` +} + +type unreadCountResponse struct { + Count int `json:"count"` +} + +type categoryResponse struct { + Name *i18n.Message `json:"name"` + ID string `json:"id"` +} + +type configurationRequest struct { + Parameters map[string]any `json:"parameters"` + Name string `json:"name"` + Provider string `json:"provider"` + Categories json.RawMessage `json:"categories"` + Enabled bool `json:"enabled"` +} + +type configurationResponse struct { + Parameters map[string]any `json:"parameters"` + Name string `json:"name"` + Provider string `json:"provider"` + Categories json.RawMessage `json:"categories"` + ID uuid.UUID `json:"id"` + Enabled bool `json:"enabled"` +} + +type availableProviderResponse struct { + Name *i18n.Message `json:"name"` + ID string `json:"id"` + ImportantInstructions []*i18n.Message `json:"importantInstructions"` + ConfigurationFields []dynamicfield.Response `json:"configurationFields"` +} diff --git a/api/notification/get_configuration_handler.go b/api/notification/get_configuration_handler.go new file mode 100644 index 000000000..25e4d4c97 --- /dev/null +++ b/api/notification/get_configuration_handler.go @@ -0,0 +1,44 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type getConfigurationHandler struct { + commands notification.Commands +} + +func (h getConfigurationHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + + data, err := h.commands.GetConfiguration(ctx.Request.Context(), userID, id) + if err != nil { + panic(err) + } + + if data == nil { + ctx.Status(http.StatusNotFound) + return + } + + response, err := toConfigurationDTO(data) + if err != nil { + panic(err) + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/api/notification/get_configuration_handler_test.go b/api/notification/get_configuration_handler_test.go new file mode 100644 index 000000000..ee406668c --- /dev/null +++ b/api/notification/get_configuration_handler_test.go @@ -0,0 +1,106 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_getConfigurationHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with configuration data on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + configuration := sampleConfiguration(userID) + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + GetConfiguration(gomock.Any(), userID, configuration.ID). + Return(&configuration, nil) + + handler := getConfigurationHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/"+configuration.ID.String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response configurationResponse + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Equal(t, configuration.ID, response.ID) + assert.Equal(t, configuration.Name, response.Name) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := getConfigurationHandler{commands: nil} + engine := gin.New() + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/"+uuid.New().String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + + t.Run("returns 404 Not Found on invalid ID", func(t *testing.T) { + userID := uuid.New() + handler := getConfigurationHandler{commands: nil} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/invalid", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + + t.Run("returns 404 Not Found when configuration does not exist", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + configurationID := uuid.New() + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + GetConfiguration(gomock.Any(), userID, configurationID). + Return(nil, nil) + + handler := getConfigurationHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/"+configurationID.String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + }) +} diff --git a/api/notification/get_handler.go b/api/notification/get_handler.go new file mode 100644 index 000000000..2fe3b30ba --- /dev/null +++ b/api/notification/get_handler.go @@ -0,0 +1,39 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type getHandler struct { + commands notification.Commands +} + +func (h getHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + + data, err := h.commands.GetNotification(ctx.Request.Context(), userID, id) + if err != nil { + panic(err) + } + + if data == nil { + ctx.Status(http.StatusNotFound) + return + } + + ctx.JSON(http.StatusOK, toNotificationDTO(data)) +} diff --git a/api/notification/get_handler_test.go b/api/notification/get_handler_test.go new file mode 100644 index 000000000..f9309783f --- /dev/null +++ b/api/notification/get_handler_test.go @@ -0,0 +1,93 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func Test_getHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with notification data on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + subject := sampleNotification(userID) + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + GetNotification(gomock.Any(), userID, subject.ID). + Return(subject, nil) + + handler := getHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/"+subject.ID.String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response notificationResponse + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Equal(t, subject.ID, response.ID) + assert.Equal(t, subject.Title, response.Title) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := getHandler{commands: nil} + engine := gin.New() + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/"+uuid.New().String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + + t.Run("returns 404 Not Found when notification does not exist", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + notificationID := uuid.New() + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + GetNotification(gomock.Any(), userID, notificationID). + Return(nil, nil) + + handler := getHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/:id", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/"+notificationID.String(), nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + }) +} diff --git a/api/notification/list_configurations_handler.go b/api/notification/list_configurations_handler.go new file mode 100644 index 000000000..578ff07cf --- /dev/null +++ b/api/notification/list_configurations_handler.go @@ -0,0 +1,36 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type listConfigurationsHandler struct { + commands notification.Commands +} + +func (h listConfigurationsHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + data, err := h.commands.ListConfigurations(ctx.Request.Context(), userID) + if err != nil { + panic(err) + } + + payload := make([]configurationResponse, len(data)) + for index, item := range data { + response, err := toConfigurationDTO(&item) + if err != nil { + panic(err) + } + payload[index] = response + } + + ctx.JSON(http.StatusOK, payload) +} diff --git a/api/notification/list_configurations_handler_test.go b/api/notification/list_configurations_handler_test.go new file mode 100644 index 000000000..f6127ee01 --- /dev/null +++ b/api/notification/list_configurations_handler_test.go @@ -0,0 +1,74 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_listConfigurationsHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with configuration list on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + configuration := sampleConfiguration(userID) + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + ListConfigurations(gomock.Any(), userID). + Return([]notification.Configuration{configuration}, nil) + + handler := listConfigurationsHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response []configurationResponse + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Len(t, response, 1) + assert.Equal(t, configuration.ID, response[0].ID) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := listConfigurationsHandler{commands: nil} + engine := gin.New() + engine.GET("", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + }) +} + +func sampleConfiguration(userID uuid.UUID) notification.Configuration { + return notification.Configuration{ + ID: uuid.New(), + UserID: userID, + Name: "Primary SMTP", + Provider: "SMTP", + Enabled: true, + Parameters: map[string]any{"host": "smtp.example.com"}, + } +} diff --git a/api/notification/list_handler.go b/api/notification/list_handler.go new file mode 100644 index 000000000..d2ee61dc5 --- /dev/null +++ b/api/notification/list_handler.go @@ -0,0 +1,39 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/api/common/pagination" + "dillmann.com.br/nginx-ignition/core/notification" +) + +type listHandler struct { + commands notification.Commands +} + +func (h listHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + pageSize, pageNumber, searchTerms, err := pagination.ExtractPaginationParameters(ctx) + if err != nil { + panic(err) + } + + page, err := h.commands.ListNotifications( + ctx.Request.Context(), + userID, + pageSize, + pageNumber, + searchTerms, + ) + if err != nil { + panic(err) + } + + ctx.JSON(http.StatusOK, pagination.Convert(page, toNotificationDTO)) +} diff --git a/api/notification/list_handler_test.go b/api/notification/list_handler_test.go new file mode 100644 index 000000000..ae049c2e7 --- /dev/null +++ b/api/notification/list_handler_test.go @@ -0,0 +1,98 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/api/common/pagination" + corepagination "dillmann.com.br/nginx-ignition/core/common/pagination" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_listHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with notification list on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + item := sampleNotification(userID) + page := corepagination.New(1, 10, 1, []notification.Notification{*item}) + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + ListNotifications(gomock.Any(), userID, gomock.Any(), gomock.Any(), gomock.Any()). + Return(page, nil) + + handler := listHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/?pageSize=10&pageNumber=1", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response pagination.PageDTO[notificationResponse] + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Len(t, response.Contents, 1) + assert.Equal(t, item.ID, response.Contents[0].ID) + }) + + t.Run("passes search terms to command", func(t *testing.T) { + searchTerm := new("certificate") + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + item := sampleNotification(userID) + page := corepagination.New(1, 10, 1, []notification.Notification{*item}) + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + ListNotifications(gomock.Any(), userID, gomock.Any(), gomock.Any(), gomock.Eq(searchTerm)). + Return(page, nil) + + handler := listHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodGet, + "/?searchTerms="+*searchTerm+"&pageSize=10&pageNumber=1", + nil, + ) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := listHandler{commands: nil} + engine := gin.New() + engine.GET("", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + }) +} diff --git a/api/notification/mark_all_as_read_handler.go b/api/notification/mark_all_as_read_handler.go new file mode 100644 index 000000000..dee83ca99 --- /dev/null +++ b/api/notification/mark_all_as_read_handler.go @@ -0,0 +1,26 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type markAllAsReadHandler struct { + commands notification.Commands +} + +func (h markAllAsReadHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + if err := h.commands.MarkAllAsRead(ctx.Request.Context(), userID); err != nil { + panic(err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/api/notification/mark_all_as_read_handler_test.go b/api/notification/mark_all_as_read_handler_test.go new file mode 100644 index 000000000..e12624aaa --- /dev/null +++ b/api/notification/mark_all_as_read_handler_test.go @@ -0,0 +1,57 @@ +package notification + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_markAllAsReadHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 204 No Content on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + MarkAllAsRead(gomock.Any(), userID). + Return(nil) + + handler := markAllAsReadHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.POST("/mark-all-as-read", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/mark-all-as-read", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNoContent, recorder.Code) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := markAllAsReadHandler{commands: nil} + engine := gin.New() + engine.POST("/mark-all-as-read", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/mark-all-as-read", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + }) +} diff --git a/api/notification/mark_as_read_handler.go b/api/notification/mark_as_read_handler.go new file mode 100644 index 000000000..8c586a8bb --- /dev/null +++ b/api/notification/mark_as_read_handler.go @@ -0,0 +1,33 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type markAsReadHandler struct { + commands notification.Commands +} + +func (h markAsReadHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + + if err := h.commands.MarkAsRead(ctx.Request.Context(), userID, id); err != nil { + panic(err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/api/notification/mark_as_read_handler_test.go b/api/notification/mark_as_read_handler_test.go new file mode 100644 index 000000000..3c97cc1bb --- /dev/null +++ b/api/notification/mark_as_read_handler_test.go @@ -0,0 +1,66 @@ +package notification + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_markAsReadHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 204 No Content on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + notificationID := uuid.New() + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + MarkAsRead(gomock.Any(), userID, notificationID). + Return(nil) + + handler := markAsReadHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.POST("/:id/mark-as-read", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/"+notificationID.String()+"/mark-as-read", + nil, + ) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNoContent, recorder.Code) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := markAsReadHandler{commands: nil} + engine := gin.New() + engine.POST("/:id/mark-as-read", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/"+uuid.New().String()+"/mark-as-read", + nil, + ) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + }) +} diff --git a/api/notification/put_configuration_handler.go b/api/notification/put_configuration_handler.go new file mode 100644 index 000000000..fd5cf427b --- /dev/null +++ b/api/notification/put_configuration_handler.go @@ -0,0 +1,52 @@ +package notification + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type putConfigurationHandler struct { + commands notification.Commands +} + +func (h putConfigurationHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + payload := &configurationRequest{} + if err := ctx.BindJSON(payload); err != nil { + panic(err) + } + + id, err := uuid.Parse(ctx.Param("id")) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + + domainModel, err := toConfigurationDomain(userID, id, payload) + if err != nil { + panic(err) + } + + if err := h.commands.SaveConfiguration( + ctx.Request.Context(), + userID, + domainModel, + ); err != nil { + if errors.Is(err, notification.ErrConfigurationNotFound) { + ctx.Status(http.StatusNotFound) + return + } + panic(err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/api/notification/put_configuration_handler_test.go b/api/notification/put_configuration_handler_test.go new file mode 100644 index 000000000..1c79e2954 --- /dev/null +++ b/api/notification/put_configuration_handler_test.go @@ -0,0 +1,61 @@ +package notification + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_putConfigurationHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 404 when configuration is owned by another user", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + configurationID := uuid.New() + payload := configurationRequest{ + Name: "Primary SMTP", + Provider: "SMTP", + Enabled: true, + Parameters: map[string]any{"host": "smtp.example.com"}, + Categories: json.RawMessage("null"), + } + + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + SaveConfiguration(gomock.Any(), userID, gomock.Any()). + Return(notification.ErrConfigurationNotFound) + + handler := putConfigurationHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.PUT("/:id", handler.handle) + + body, _ := json.Marshal(payload) + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPut, + "/"+configurationID.String(), + bytes.NewBuffer(body), + ) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + }) +} diff --git a/api/notification/routes.go b/api/notification/routes.go new file mode 100644 index 000000000..940ad674c --- /dev/null +++ b/api/notification/routes.go @@ -0,0 +1,57 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" +) + +const ( + configurationsPath = "/api/notifications/configurations" + configurationByIDPath = "/api/notifications/configurations/:id" +) + +func Install( + router *gin.Engine, + authorizer *authorization.ABAC, + commands notification.Commands, +) { + inboxPath := router.Group("/api/notifications") + inboxPath.GET("/categories", categoriesHandler{commands}.handle) + inboxPath.GET("/unread-count", unreadCountHandler{commands}.handle) + inboxPath.POST("/mark-all-as-read", markAllAsReadHandler{commands}.handle) + inboxPath.GET("", listHandler{commands}.handle) + + byIDPath := inboxPath.Group("/:id") + byIDPath.GET("", getHandler{commands}.handle) + byIDPath.POST("/mark-as-read", markAsReadHandler{commands}.handle) + + configPath := router.Group(configurationsPath) + configPath.GET("/available-providers", availableProvidersHandler{commands}.handle) + configPath.GET("", listConfigurationsHandler{commands}.handle) + configPath.POST("", createConfigurationHandler{commands}.handle) + + configByIDPath := configPath.Group("/:id") + configByIDPath.GET("", getConfigurationHandler{commands}.handle) + configByIDPath.PUT("", putConfigurationHandler{commands}.handle) + configByIDPath.DELETE("", deleteConfigurationHandler{commands}.handle) + + authorizer.AllowAllUsers(http.MethodGet, "/api/notifications/categories") + authorizer.AllowAllUsers(http.MethodGet, "/api/notifications/unread-count") + authorizer.AllowAllUsers(http.MethodPost, "/api/notifications/mark-all-as-read") + authorizer.AllowAllUsers(http.MethodGet, "/api/notifications") + authorizer.AllowAllUsers(http.MethodGet, "/api/notifications/:id") + authorizer.AllowAllUsers(http.MethodPost, "/api/notifications/:id/mark-as-read") + authorizer.AllowAllUsers( + http.MethodGet, + "/api/notifications/configurations/available-providers", + ) + authorizer.AllowAllUsers(http.MethodGet, configurationsPath) + authorizer.AllowAllUsers(http.MethodPost, configurationsPath) + authorizer.AllowAllUsers(http.MethodGet, configurationByIDPath) + authorizer.AllowAllUsers(http.MethodPut, configurationByIDPath) + authorizer.AllowAllUsers(http.MethodDelete, configurationByIDPath) +} diff --git a/api/notification/subject.go b/api/notification/subject.go new file mode 100644 index 000000000..6ee8032aa --- /dev/null +++ b/api/notification/subject.go @@ -0,0 +1,20 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/api/common/authorization" +) + +func currentUserID(ctx *gin.Context) (uuid.UUID, bool) { + subject := authorization.CurrentSubject(ctx) + if subject == nil || subject.User == nil { + ctx.Status(http.StatusUnauthorized) + return uuid.Nil, false + } + + return subject.User.ID, true +} diff --git a/api/notification/unread_count_handler.go b/api/notification/unread_count_handler.go new file mode 100644 index 000000000..6f18454a9 --- /dev/null +++ b/api/notification/unread_count_handler.go @@ -0,0 +1,27 @@ +package notification + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type unreadCountHandler struct { + commands notification.Commands +} + +func (h unreadCountHandler) handle(ctx *gin.Context) { + userID, ok := currentUserID(ctx) + if !ok { + return + } + + count, err := h.commands.UnreadCount(ctx.Request.Context(), userID) + if err != nil { + panic(err) + } + + ctx.JSON(http.StatusOK, unreadCountResponse{Count: count}) +} diff --git a/api/notification/unread_count_handler_test.go b/api/notification/unread_count_handler_test.go new file mode 100644 index 000000000..6d6921bb3 --- /dev/null +++ b/api/notification/unread_count_handler_test.go @@ -0,0 +1,61 @@ +package notification + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/api/common/authorization" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_unreadCountHandler(t *testing.T) { + t.Run("handle", func(t *testing.T) { + t.Run("returns 200 OK with unread count on success", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + userID := uuid.New() + commands := notification.NewMockedCommands(controller) + commands.EXPECT(). + UnreadCount(gomock.Any(), userID). + Return(3, nil) + + handler := unreadCountHandler{commands: commands} + engine := gin.New() + engine.Use(func(ginContext *gin.Context) { + ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: userID}}) + ginContext.Next() + }) + engine.GET("/unread-count", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/unread-count", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + var response unreadCountResponse + json.Unmarshal(recorder.Body.Bytes(), &response) + assert.Equal(t, 3, response.Count) + }) + + t.Run("returns 401 Unauthorized without subject", func(t *testing.T) { + handler := unreadCountHandler{commands: nil} + engine := gin.New() + engine.GET("/unread-count", handler.handle) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/unread-count", nil) + engine.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) + }) +} diff --git a/api/user/artifacts_test.go b/api/user/artifacts_test.go index 475078525..485ee7f57 100644 --- a/api/user/artifacts_test.go +++ b/api/user/artifacts_test.go @@ -9,10 +9,11 @@ import ( func newUser() *user.User { return &user.User{ - ID: uuid.New(), - Name: "Test User", - Username: "testuser", - Enabled: true, + ID: uuid.New(), + Name: "Test User", + Username: "testuser", + NotificationLanguage: "en", + Enabled: true, Permissions: user.Permissions{ Hosts: user.ReadWriteAccessLevel, Streams: user.ReadWriteAccessLevel, @@ -32,11 +33,12 @@ func newUser() *user.User { func newUserRequest() userRequestDTO { return userRequestDTO{ - Name: new("Test User"), - Username: new("testuser"), - Password: new("password123"), - Enabled: new(true), - RemoveTOTP: new(false), + Name: new("Test User"), + Username: new("testuser"), + Password: new("password123"), + NotificationLanguage: "en", + Enabled: new(true), + RemoveTOTP: new(false), Permissions: userPermissionsDTO{ Hosts: string(user.ReadWriteAccessLevel), Streams: string(user.ReadWriteAccessLevel), diff --git a/api/user/converter.go b/api/user/converter.go index 6f17ac149..6b27adea2 100644 --- a/api/user/converter.go +++ b/api/user/converter.go @@ -14,12 +14,13 @@ func toDomain(dto *userRequestDTO) *user.SaveRequest { } return &user.SaveRequest{ - ID: uuid.New(), - Enabled: getBoolValue(dto.Enabled), - RemoveTOTP: getBoolValue(dto.RemoveTOTP), - Name: getStringValue(dto.Name), - Username: getStringValue(dto.Username), - Password: dto.Password, + ID: uuid.New(), + Enabled: getBoolValue(dto.Enabled), + RemoveTOTP: getBoolValue(dto.RemoveTOTP), + Name: getStringValue(dto.Name), + Username: getStringValue(dto.Username), + Password: dto.Password, + NotificationLanguage: dto.NotificationLanguage, Permissions: user.Permissions{ Hosts: user.AccessLevel(dto.Permissions.Hosts), Streams: user.AccessLevel(dto.Permissions.Streams), @@ -51,11 +52,12 @@ func toDTO(domain *user.User) *userResponseDTO { } return &userResponseDTO{ - ID: domain.ID, - Enabled: domain.Enabled, - TOTPEnabled: totpEnabled, - Name: domain.Name, - Username: domain.Username, + ID: domain.ID, + Enabled: domain.Enabled, + TOTPEnabled: totpEnabled, + Name: domain.Name, + Username: domain.Username, + NotificationLanguage: domain.NotificationLanguage, Permissions: userPermissionsDTO{ Hosts: string(domain.Permissions.Hosts), Streams: string(domain.Permissions.Streams), diff --git a/api/user/converter_test.go b/api/user/converter_test.go index efc3a5caa..5dc00673d 100644 --- a/api/user/converter_test.go +++ b/api/user/converter_test.go @@ -21,6 +21,7 @@ func Test_toDTO(t *testing.T) { assert.Equal(t, subject.ID, result.ID) assert.Equal(t, subject.Name, result.Name) assert.Equal(t, subject.Username, result.Username) + assert.Equal(t, subject.NotificationLanguage, result.NotificationLanguage) assert.True(t, result.Enabled) assert.True(t, result.TOTPEnabled) assert.Equal(t, string(user.ReadWriteAccessLevel), result.Permissions.Hosts) @@ -85,6 +86,7 @@ func Test_toDomain(t *testing.T) { assert.NotNil(t, result) assert.Equal(t, *payload.Name, result.Name) assert.Equal(t, *payload.Username, result.Username) + assert.Equal(t, payload.NotificationLanguage, result.NotificationLanguage) assert.True(t, result.Enabled) assert.False(t, result.RemoveTOTP) assert.Equal(t, user.ReadWriteAccessLevel, result.Permissions.Hosts) diff --git a/api/user/current_handler_test.go b/api/user/current_handler_test.go index 14e09e4a6..5f37cc5bf 100644 --- a/api/user/current_handler_test.go +++ b/api/user/current_handler_test.go @@ -40,6 +40,7 @@ func Test_currentHandler(t *testing.T) { var response userResponseDTO json.Unmarshal(recorder.Body.Bytes(), &response) assert.Equal(t, subject.ID, response.ID) + assert.Equal(t, subject.NotificationLanguage, response.NotificationLanguage) }) t.Run("returns 401 Unauthorized when subject is missing", func(t *testing.T) { diff --git a/api/user/dto.go b/api/user/dto.go index 122eb2b3b..d78471d2f 100644 --- a/api/user/dto.go +++ b/api/user/dto.go @@ -24,26 +24,29 @@ type userPasswordUpdateRequestDTO struct { } type userProfileUpdateRequestDTO struct { - Name *string `json:"name"` - Username *string `json:"username"` + Name *string `json:"name"` + Username *string `json:"username"` + NotificationLanguage string `json:"notificationLanguage"` } type userRequestDTO struct { - Enabled *bool `json:"enabled"` - RemoveTOTP *bool `json:"removeTotp"` - Name *string `json:"name"` - Username *string `json:"username"` - Password *string `json:"password,omitempty"` - Permissions userPermissionsDTO `json:"permissions"` + Enabled *bool `json:"enabled"` + RemoveTOTP *bool `json:"removeTotp"` + Name *string `json:"name"` + Username *string `json:"username"` + Password *string `json:"password,omitempty"` + NotificationLanguage string `json:"notificationLanguage"` + Permissions userPermissionsDTO `json:"permissions"` } type userResponseDTO struct { - Permissions userPermissionsDTO `json:"permissions"` - Name string `json:"name"` - Username string `json:"username"` - ID uuid.UUID `json:"id"` - Enabled bool `json:"enabled"` - TOTPEnabled bool `json:"totpEnabled"` + Permissions userPermissionsDTO `json:"permissions"` + Name string `json:"name"` + Username string `json:"username"` + NotificationLanguage string `json:"notificationLanguage"` + ID uuid.UUID `json:"id"` + Enabled bool `json:"enabled"` + TOTPEnabled bool `json:"totpEnabled"` } type userPermissionsDTO struct { diff --git a/api/user/update_profile_handler.go b/api/user/update_profile_handler.go index 1dbd4f084..38d8f9bfb 100644 --- a/api/user/update_profile_handler.go +++ b/api/user/update_profile_handler.go @@ -6,11 +6,11 @@ import ( "github.com/gin-gonic/gin" "dillmann.com.br/nginx-ignition/api/common/authorization" - "dillmann.com.br/nginx-ignition/core/user" + coreuser "dillmann.com.br/nginx-ignition/core/user" ) type updateProfileHandler struct { - commands user.Commands + commands coreuser.Commands } func (h updateProfileHandler) handle(ctx *gin.Context) { @@ -26,6 +26,7 @@ func (h updateProfileHandler) handle(ctx *gin.Context) { currentUserID, getStringValue(payload.Name), getStringValue(payload.Username), + payload.NotificationLanguage, ); err != nil { panic(err) } diff --git a/api/user/update_profile_handler_test.go b/api/user/update_profile_handler_test.go index 0f36210a4..645848888 100644 --- a/api/user/update_profile_handler_test.go +++ b/api/user/update_profile_handler_test.go @@ -16,6 +16,10 @@ import ( "dillmann.com.br/nginx-ignition/core/user" ) +func init() { + gin.SetMode(gin.TestMode) +} + func Test_updateProfileHandler(t *testing.T) { t.Run("handle", func(t *testing.T) { t.Run("returns 204 No Content on success", func(t *testing.T) { @@ -24,18 +28,23 @@ func Test_updateProfileHandler(t *testing.T) { id := uuid.New() payload := userProfileUpdateRequestDTO{ - Name: new("Updated Name"), - Username: new("updateduser"), + Name: new("Updated Name"), + Username: new("updateduser"), + NotificationLanguage: "en", } commands := user.NewMockedCommands(controller) commands.EXPECT(). - UpdateProfile(gomock.Any(), id, *payload.Name, *payload.Username). + UpdateProfile( + gomock.Any(), + id, + *payload.Name, + *payload.Username, + payload.NotificationLanguage, + ). Return(nil) - handler := updateProfileHandler{ - commands: commands, - } + handler := updateProfileHandler{commands: commands} engine := gin.New() engine.Use(func(ginContext *gin.Context) { ginContext.Set("ABAC:Subject", &authorization.Subject{User: &user.User{ID: id}}) diff --git a/application/boot/container.go b/application/boot/container.go index 93a94c0db..4ab194f11 100644 --- a/application/boot/container.go +++ b/application/boot/container.go @@ -16,10 +16,12 @@ import ( "dillmann.com.br/nginx-ignition/core/common/i18n" "dillmann.com.br/nginx-ignition/core/common/lifecycle" "dillmann.com.br/nginx-ignition/core/integration" + "dillmann.com.br/nginx-ignition/core/notification" "dillmann.com.br/nginx-ignition/core/vpn" "dillmann.com.br/nginx-ignition/database" "dillmann.com.br/nginx-ignition/integration/docker" "dillmann.com.br/nginx-ignition/integration/truenas" + "dillmann.com.br/nginx-ignition/notification/smtp" "dillmann.com.br/nginx-ignition/vpn/netbird" "dillmann.com.br/nginx-ignition/vpn/tailscale" ) @@ -48,9 +50,11 @@ func startContainer(ctx context.Context) error { truenas.Install, tailscale.Install, netbird.Install, + smtp.Install, installCertificateDriverAggregation, installIntegrationDriverAggregation, installVpnDriverAggregation, + installNotificationProviderAggregation, ) } @@ -87,3 +91,9 @@ func installVpnDriverAggregation( nb, }) } + +func installNotificationProviderAggregation( + smtpProvider *smtp.Provider, +) error { + return container.Singleton([]notification.Provider{smtpProvider}) +} diff --git a/application/go.mod b/application/go.mod index 6cf1b0c38..b2f0bd789 100644 --- a/application/go.mod +++ b/application/go.mod @@ -1,3 +1,3 @@ module dillmann.com.br/nginx-ignition/application -go 1.26.3 +go 1.26.4 diff --git a/certificate/commons/go.mod b/certificate/commons/go.mod index 42da98726..340c16c11 100644 --- a/certificate/commons/go.mod +++ b/certificate/commons/go.mod @@ -1,5 +1,5 @@ module dillmann.com.br/nginx-ignition/certificate/commons -go 1.26.3 +go 1.26.4 require go.uber.org/mock v0.6.0 diff --git a/certificate/custom/go.mod b/certificate/custom/go.mod index 270315615..563a6da86 100644 --- a/certificate/custom/go.mod +++ b/certificate/custom/go.mod @@ -1,5 +1,5 @@ module dillmann.com.br/nginx-ignition/certificate/custom -go 1.26.3 +go 1.26.4 require github.com/google/uuid v1.6.0 diff --git a/certificate/external/go.mod b/certificate/external/go.mod index f7e8c71b4..a70409a49 100644 --- a/certificate/external/go.mod +++ b/certificate/external/go.mod @@ -1,5 +1,5 @@ module dillmann.com.br/nginx-ignition/certificate/external -go 1.26.3 +go 1.26.4 require github.com/google/uuid v1.6.0 diff --git a/certificate/letsencrypt/go.mod b/certificate/letsencrypt/go.mod index 68cccf64d..e745aa3e0 100644 --- a/certificate/letsencrypt/go.mod +++ b/certificate/letsencrypt/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/certificate/letsencrypt -go 1.26.3 +go 1.26.4 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 diff --git a/certificate/selfsigned/go.mod b/certificate/selfsigned/go.mod index e20dfa62c..28fb170db 100644 --- a/certificate/selfsigned/go.mod +++ b/certificate/selfsigned/go.mod @@ -1,5 +1,5 @@ module dillmann.com.br/nginx-ignition/certificate/selfsigned -go 1.26.3 +go 1.26.4 require github.com/google/uuid v1.6.0 diff --git a/core/common/dynamicfields/sensitive_fields.go b/core/common/dynamicfields/sensitive_fields.go index 2fdd296e3..6f24c3c04 100644 --- a/core/common/dynamicfields/sensitive_fields.go +++ b/core/common/dynamicfields/sensitive_fields.go @@ -7,3 +7,29 @@ func RemoveSensitiveFields(values *map[string]any, dynamicFields []DynamicField) } } } + +func MergeSensitiveFields( + left map[string]any, + right map[string]any, + dynamicFields []DynamicField, +) map[string]any { + if left == nil { + left = map[string]any{} + } + + for _, field := range dynamicFields { + if !field.Sensitive { + continue + } + + if _, exists := left[field.ID]; exists { + continue + } + + if value, found := right[field.ID]; found { + left[field.ID] = value + } + } + + return left +} diff --git a/core/common/dynamicfields/sensitive_fields_test.go b/core/common/dynamicfields/sensitive_fields_test.go index ac209e7f2..da3d1f906 100644 --- a/core/common/dynamicfields/sensitive_fields_test.go +++ b/core/common/dynamicfields/sensitive_fields_test.go @@ -96,6 +96,30 @@ func Test_RemoveSensitiveFields(t *testing.T) { assert.NotContains(t, values, "field4") }) + t.Run("merges missing sensitive fields from right", func(t *testing.T) { + left := map[string]any{ + "field1": "updated", + } + right := map[string]any{ + "field1": "original", + "field2": "secret", + } + + dynamicField1 := newDynamicField(t.Context()) + dynamicField1.ID = "field1" + + dynamicField2 := newDynamicField(t.Context()) + dynamicField2.ID = "field2" + dynamicField2.Sensitive = true + + dynamicFields := []DynamicField{*dynamicField1, *dynamicField2} + + result := MergeSensitiveFields(left, right, dynamicFields) + + assert.Equal(t, "updated", result["field1"]) + assert.Equal(t, "secret", result["field2"]) + }) + t.Run("do nothing for non-existent fields", func(t *testing.T) { values := map[string]any{ "field1": "value1", diff --git a/core/common/i18n/builder.go b/core/common/i18n/builder.go index 732dd45d7..0d88e3a4b 100644 --- a/core/common/i18n/builder.go +++ b/core/common/i18n/builder.go @@ -7,15 +7,19 @@ import ( func Static(message string) *Message { return &Message{ static: true, - Key: message, + DetachedMessage: DetachedMessage{ + Key: message, + }, } } func M(ctx context.Context, key string) *Message { return &Message{ - ctx: ctx, - Key: key, - Variables: make(map[string]any), + ctx: ctx, + DetachedMessage: DetachedMessage{ + Key: key, + Variables: make(map[string]any), + }, } } diff --git a/core/common/i18n/model.go b/core/common/i18n/model.go index fa93ae187..d290316b9 100644 --- a/core/common/i18n/model.go +++ b/core/common/i18n/model.go @@ -9,11 +9,26 @@ import ( "dillmann.com.br/nginx-ignition/core/common/container" ) -type Message struct { - ctx context.Context +type DetachedMessage struct { Variables map[string]any Key string - static bool +} + +type Message struct { + ctx context.Context + DetachedMessage + static bool +} + +func (m Message) Detach() *DetachedMessage { + variables := make(map[string]any, len(m.Variables)) + for key, value := range m.Variables { + variables[key] = value + } + return &DetachedMessage{ + Key: m.Key, + Variables: variables, + } } func (m Message) String() string { diff --git a/core/common/i18n/model_test.go b/core/common/i18n/model_test.go index f4b3a3734..800ecc1f3 100644 --- a/core/common/i18n/model_test.go +++ b/core/common/i18n/model_test.go @@ -32,7 +32,10 @@ func Test_Message(t *testing.T) { ctx := context.WithValue(t.Context(), ContextKey, lang) key := "test-key" variables := map[string]any{"var": "val"} - message := Message{ctx: ctx, Key: key, Variables: variables} + message := Message{ + ctx: ctx, + DetachedMessage: DetachedMessage{Key: key, Variables: variables}, + } expected := "translated string" commands.EXPECT().Translate(lang, key, variables).Return(expected) @@ -53,7 +56,10 @@ func Test_Message(t *testing.T) { key := "test-key" variables := map[string]any{"var": "val"} - message := Message{ctx: t.Context(), Key: key, Variables: variables} + message := Message{ + ctx: t.Context(), + DetachedMessage: DetachedMessage{Key: key, Variables: variables}, + } defaultLang := language.AmericanEnglish commands.EXPECT().DefaultLanguage().Return(defaultLang) @@ -78,7 +84,10 @@ func Test_Message(t *testing.T) { lang := language.AmericanEnglish ctx := context.WithValue(t.Context(), ContextKey, lang) - message := Message{ctx: ctx, Key: "key"} + message := Message{ + ctx: ctx, + DetachedMessage: DetachedMessage{Key: "key"}, + } commands.EXPECT().Translate(lang, "key", gomock.Any()).Return("translated") @@ -87,4 +96,51 @@ func Test_Message(t *testing.T) { assert.Equal(t, `"translated"`, string(bytes)) }) }) + + t.Run("Detach", func(t *testing.T) { + t.Run("copies key and variables without context", func(t *testing.T) { + message := M(t.Context(), "test-key").V("var", "val") + + detached := message.Detach() + + assert.Equal(t, message.Key, detached.Key) + assert.Equal(t, message.Variables, detached.Variables) + + message.Variables["var"] = "mutated" + assert.Equal(t, "val", detached.Variables["var"]) + }) + + t.Run("detached map is independent from message variables", func(t *testing.T) { + message := M(t.Context(), "key").V("var", "val") + detached := message.Detach() + + detached.Variables["var"] = "changed" + + assert.Equal(t, "val", message.Variables["var"]) + assert.Equal(t, "changed", detached.Variables["var"]) + + message.Variables["other"] = "added" + + assert.NotContains(t, detached.Variables, "other") + }) + + t.Run("embedded message still translates via String with context", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + container.Init(t.Context()) + commands := NewMockedCommands(ctrl) + container.Singleton[Commands](commands) + + lang := language.BrazilianPortuguese + ctx := context.WithValue(t.Context(), ContextKey, lang) + key := "test-key" + variables := map[string]any{"var": "val"} + message := M(ctx, key).V("var", "val") + + commands.EXPECT().Translate(lang, key, variables).Return("translated string") + + assert.Equal(t, "translated string", message.String()) + }) + }) } diff --git a/core/common/scheduler/installer.go b/core/common/scheduler/installer.go index aca1c7e38..150f3e50a 100644 --- a/core/common/scheduler/installer.go +++ b/core/common/scheduler/installer.go @@ -7,14 +7,14 @@ import ( ) func Install() error { - if err := container.Provide(buildScheduler); err != nil { + if err := container.Provide(New); err != nil { return err } return container.Run(registerStartup, registerShutdown) } -func buildScheduler() *Scheduler { +func New() *Scheduler { return &Scheduler{ tickers: make(map[Task]*time.Ticker), stopped: false, diff --git a/core/common/scheduler/scheduler_test.go b/core/common/scheduler/scheduler_test.go index cf4f6e262..f786fdb3c 100644 --- a/core/common/scheduler/scheduler_test.go +++ b/core/common/scheduler/scheduler_test.go @@ -11,7 +11,7 @@ import ( func Test_BuildScheduler(t *testing.T) { t.Run("builds scheduler", func(t *testing.T) { - sched := buildScheduler() + sched := New() assert.NotNil(t, sched) assert.NotNil(t, sched.tickers) @@ -26,7 +26,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() task := NewMockedTask(ctrl) err := sched.Register(t.Context(), task) @@ -39,7 +39,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() sched.started = true task := NewMockedTask(ctrl) @@ -55,7 +55,7 @@ func Test_Scheduler(t *testing.T) { }) t.Run("returns error when stopped", func(t *testing.T) { - sched := buildScheduler() + sched := New() sched.stopped = true task := NewMockedTask(gomock.NewController(t)) @@ -70,7 +70,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() task := NewMockedTask(ctrl) task.EXPECT().Schedule(t.Context()).Return(&Schedule{ @@ -88,7 +88,7 @@ func Test_Scheduler(t *testing.T) { }) t.Run("returns error when already started", func(t *testing.T) { - sched := buildScheduler() + sched := New() sched.started = true err := sched.start(t.Context()) @@ -97,7 +97,7 @@ func Test_Scheduler(t *testing.T) { }) t.Run("returns error when stopped", func(t *testing.T) { - sched := buildScheduler() + sched := New() sched.stopped = true err := sched.start(t.Context()) @@ -109,7 +109,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() task := NewMockedTask(ctrl) task.EXPECT().Schedule(t.Context()).Return(nil, errors.New("schedule error")) @@ -127,7 +127,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() task := NewMockedTask(ctrl) sched.tickers[task] = time.NewTicker(time.Second) @@ -143,7 +143,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() task := NewMockedTask(ctrl) task.EXPECT().Schedule(t.Context()).Return(&Schedule{ @@ -160,7 +160,7 @@ func Test_Scheduler(t *testing.T) { }) t.Run("returns error when stopped", func(t *testing.T) { - sched := buildScheduler() + sched := New() sched.stopped = true err := sched.Reload(t.Context()) @@ -172,7 +172,7 @@ func Test_Scheduler(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - sched := buildScheduler() + sched := New() task := NewMockedTask(ctrl) task.EXPECT().Schedule(t.Context()).Return(nil, errors.New("schedule error")) diff --git a/core/go.mod b/core/go.mod index ccaad39cd..33ba5ff55 100644 --- a/core/go.mod +++ b/core/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/core -go 1.26.3 +go 1.26.4 require ( github.com/google/uuid v1.6.0 diff --git a/core/installer.go b/core/installer.go index 70e3a7a97..0eed1507a 100644 --- a/core/installer.go +++ b/core/installer.go @@ -12,6 +12,7 @@ import ( "dillmann.com.br/nginx-ignition/core/host" "dillmann.com.br/nginx-ignition/core/integration" "dillmann.com.br/nginx-ignition/core/nginx" + "dillmann.com.br/nginx-ignition/core/notification" "dillmann.com.br/nginx-ignition/core/settings" "dillmann.com.br/nginx-ignition/core/stream" "dillmann.com.br/nginx-ignition/core/user" @@ -31,6 +32,7 @@ func Install() error { vpn.Install, host.Install, integration.Install, + notification.Install, stream.Install, nginx.Install, backup.Install, diff --git a/core/notification/artifacts_test.go b/core/notification/artifacts_test.go new file mode 100644 index 000000000..3646e4c86 --- /dev/null +++ b/core/notification/artifacts_test.go @@ -0,0 +1,97 @@ +package notification + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" + "dillmann.com.br/nginx-ignition/core/common/i18n" +) + +type testProvider struct{} + +func (testProvider) ID() string { return "SMTP" } + +func (testProvider) Name(_ context.Context) *i18n.Message { + return i18n.Static("Test") +} + +func (testProvider) ImportantInstructions(_ context.Context) []*i18n.Message { + return nil +} + +func (testProvider) ConfigurationFields(_ context.Context) []dynamicfields.DynamicField { + return []dynamicfields.DynamicField{ + { + ID: "host", + Sensitive: false, + Type: dynamicfields.SingleLineTextType, + }, + { + ID: "password", + Sensitive: true, + Type: dynamicfields.SingleLineTextType, + }, + } +} + +func (testProvider) Send( + _ context.Context, + _ map[string]any, + _ Deliverable, +) error { + return nil +} + +func newConfiguration() *Configuration { + return &Configuration{ + ID: uuid.New(), + Name: "test", + Provider: "SMTP", + Enabled: true, + Parameters: map[string]any{}, + } +} + +func testProviders() []Provider { + return []Provider{testProvider{}} +} + +type failingTestProvider struct { + testProvider + sendError error +} + +func (provider failingTestProvider) Send( + _ context.Context, + _ map[string]any, + _ Deliverable, +) error { + return provider.sendError +} + +func failingTestProviders(sendError error) func() []Provider { + return func() []Provider { + return []Provider{failingTestProvider{sendError: sendError}} + } +} + +type requiredHostProvider struct { + testProvider +} + +func (requiredHostProvider) ConfigurationFields( + _ context.Context, +) []dynamicfields.DynamicField { + return []dynamicfields.DynamicField{ + { + ID: "host", + Required: true, + Type: dynamicfields.SingleLineTextType, + }, + } +} + +var errSendFailed = errors.New("send failed") diff --git a/core/notification/commands.go b/core/notification/commands.go new file mode 100644 index 000000000..2a6f84221 --- /dev/null +++ b/core/notification/commands.go @@ -0,0 +1,50 @@ +package notification + +import ( + "context" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/common/pagination" +) + +type AvailableProvider struct { + Name *i18n.Message + ID string + ImportantInstructions []*i18n.Message + ConfigurationFields []dynamicfields.DynamicField +} + +type Commands interface { + ListNotifications( + ctx context.Context, + userID uuid.UUID, + pageSize, pageNumber int, + searchTerms *string, + ) (*pagination.Page[Notification], error) + GetNotification(ctx context.Context, userID, id uuid.UUID) (*Notification, error) + MarkAsRead(ctx context.Context, userID, id uuid.UUID) error + MarkAllAsRead(ctx context.Context, userID uuid.UUID) error + UnreadCount(ctx context.Context, userID uuid.UUID) (int, error) + + GetLastForUserCategoryAndRelatedEntity( + ctx context.Context, + userID uuid.UUID, + category Category, + entityType string, + entityID uuid.UUID, + ) (*Notification, error) + + ListConfigurations(ctx context.Context, userID uuid.UUID) ([]Configuration, error) + GetConfiguration(ctx context.Context, userID, id uuid.UUID) (*Configuration, error) + SaveConfiguration(ctx context.Context, userID uuid.UUID, configuration *Configuration) error + DeleteConfiguration(ctx context.Context, userID, id uuid.UUID) error + GetAvailableProviders(ctx context.Context) ([]AvailableProvider, error) + ListCategories(ctx context.Context) ([]CategoryInfo, error) + + Publish(ctx context.Context, userID uuid.UUID, request SendRequest) (*Notification, error) + Broadcast(ctx context.Context, request SendRequest) error + ProcessPendingDeliveries(ctx context.Context) error +} diff --git a/core/notification/constants.go b/core/notification/constants.go new file mode 100644 index 000000000..972b51a8e --- /dev/null +++ b/core/notification/constants.go @@ -0,0 +1,11 @@ +package notification + +import "errors" + +const ( + maxDeliveryAttempts = 5 + + submissionDeliveryFailureLogFormat = "notification delivery failed for submission %s: %s" +) + +var ErrConfigurationNotFound = errors.New("notification configuration not found") diff --git a/core/notification/delivery_task.go b/core/notification/delivery_task.go new file mode 100644 index 000000000..ef456c3e0 --- /dev/null +++ b/core/notification/delivery_task.go @@ -0,0 +1,42 @@ +package notification + +import ( + "context" + "time" + + "dillmann.com.br/nginx-ignition/core/common/log" + "dillmann.com.br/nginx-ignition/core/common/scheduler" +) + +const deliveryTaskInterval = 30 * time.Second + +type deliveryTask struct { + commands Commands +} + +func registerScheduledTask( + ctx context.Context, + commands Commands, + sched *scheduler.Scheduler, +) error { + task := deliveryTask{commands: commands} + return sched.Register(ctx, &task) +} + +func (t *deliveryTask) Run(ctx context.Context) error { + return t.commands.ProcessPendingDeliveries(ctx) +} + +func (t *deliveryTask) Schedule(_ context.Context) (*scheduler.Schedule, error) { + return &scheduler.Schedule{ + Enabled: true, + Interval: deliveryTaskInterval, + }, nil +} + +func (t *deliveryTask) OnScheduleStarted(_ context.Context) { + log.Infof( + "Notification delivery task scheduled to run every %v seconds", + deliveryTaskInterval.Seconds(), + ) +} diff --git a/core/notification/delivery_task_test.go b/core/notification/delivery_task_test.go new file mode 100644 index 000000000..8df13e82a --- /dev/null +++ b/core/notification/delivery_task_test.go @@ -0,0 +1,83 @@ +package notification + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/core/common/scheduler" +) + +func Test_deliveryTask(t *testing.T) { + t.Run("Schedule", func(t *testing.T) { + t.Run("returns enabled schedule with 30 second interval", func(t *testing.T) { + task := &deliveryTask{} + + schedule, err := task.Schedule(t.Context()) + + assert.NoError(t, err) + assert.True(t, schedule.Enabled) + assert.Equal(t, 30*time.Second, schedule.Interval) + }) + }) + + t.Run("Run", func(t *testing.T) { + t.Run("calls ProcessPendingDeliveries", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + commands := NewMockedCommands(ctrl) + commands.EXPECT().ProcessPendingDeliveries(t.Context()).Return(nil) + + task := deliveryTask{commands: commands} + + err := task.Run(t.Context()) + + assert.NoError(t, err) + }) + + t.Run("propagates ProcessPendingDeliveries errors", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + commands := NewMockedCommands(ctrl) + commands.EXPECT().ProcessPendingDeliveries(t.Context()).Return(assert.AnError) + + task := deliveryTask{commands: commands} + + err := task.Run(t.Context()) + + assert.Error(t, err) + assert.Equal(t, assert.AnError, err) + }) + }) + + t.Run("registerScheduledTask", func(t *testing.T) { + t.Run("wires delivery task to commands", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + commands := NewMockedCommands(ctrl) + commands.EXPECT().ProcessPendingDeliveries(t.Context()).Return(nil) + + task := deliveryTask{commands: commands} + err := task.Run(t.Context()) + + assert.NoError(t, err) + }) + + t.Run("registers task with scheduler", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + commands := NewMockedCommands(ctrl) + sched := scheduler.New() + + err := registerScheduledTask(t.Context(), commands, sched) + + assert.NoError(t, err) + }) + }) +} diff --git a/core/notification/installer.go b/core/notification/installer.go new file mode 100644 index 000000000..ae02ebae4 --- /dev/null +++ b/core/notification/installer.go @@ -0,0 +1,34 @@ +package notification + +import ( + "dillmann.com.br/nginx-ignition/core/common/container" + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Install() error { + if err := container.Provide(newCommands); err != nil { + return err + } + + return container.Run(registerScheduledTask) +} + +func newCommands( + repository Repository, + userCommands user.Commands, + i18nCommands i18n.Commands, +) (Commands, *service) { + providers := func() []Provider { + return container.Get[[]Provider]() + } + + serviceInstance := newService( + repository, + userCommands, + i18nCommands, + providers, + ) + + return serviceInstance, serviceInstance +} diff --git a/core/notification/model.go b/core/notification/model.go new file mode 100644 index 000000000..690758578 --- /dev/null +++ b/core/notification/model.go @@ -0,0 +1,170 @@ +package notification + +import ( + "context" + "time" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/i18n" +) + +type Category string + +const ( + CategoryCertificateRenewed Category = "CERTIFICATE_RENEWED" + CategoryCertificateRenewFailed Category = "CERTIFICATE_RENEW_FAILED" + CategoryCertificateExpiring Category = "CERTIFICATE_EXPIRING" + CategoryNginxReloadFailed Category = "NGINX_RELOAD_FAILED" + CategoryNginxReloadSucceeded Category = "NGINX_RELOAD_SUCCEEDED" +) + +func AllCategories() []Category { + return []Category{ + CategoryCertificateRenewed, + CategoryCertificateRenewFailed, + CategoryCertificateExpiring, + CategoryNginxReloadFailed, + CategoryNginxReloadSucceeded, + } +} + +func CategoryName(ctx context.Context, category Category) *i18n.Message { + switch category { + case CategoryCertificateRenewed: + return i18n.M(ctx, i18n.K.CoreNotificationCategoryCertificateRenewed) + case CategoryCertificateRenewFailed: + return i18n.M(ctx, i18n.K.CoreNotificationCategoryCertificateRenewFailed) + case CategoryCertificateExpiring: + return i18n.M(ctx, i18n.K.CoreNotificationCategoryCertificateExpiring) + case CategoryNginxReloadFailed: + return i18n.M(ctx, i18n.K.CoreNotificationCategoryNginxReloadFailed) + case CategoryNginxReloadSucceeded: + return i18n.M(ctx, i18n.K.CoreNotificationCategoryNginxReloadSucceeded) + default: + return i18n.M(ctx, i18n.K.CoreNotificationCategoryUnknown) + } +} + +func isValidCategory(category Category) bool { + switch category { + case CategoryCertificateRenewed, + CategoryCertificateRenewFailed, + CategoryCertificateExpiring, + CategoryNginxReloadFailed, + CategoryNginxReloadSucceeded: + return true + default: + return false + } +} + +func configurationAcceptsCategory(allowed *[]Category, category Category) bool { + if allowed == nil { + return true + } + + if len(*allowed) == 0 { + return false + } + + for _, item := range *allowed { + if item == category { + return true + } + } + + return false +} + +type CategoryInfo struct { + Name *i18n.Message + ID Category +} + +type SubmissionStatus string + +const ( + SubmissionStatusPending SubmissionStatus = "PENDING" + SubmissionStatusSuccess SubmissionStatus = "SUCCESS" + SubmissionStatusFailed SubmissionStatus = "FAILED" + SubmissionStatusSkipped SubmissionStatus = "SKIPPED" +) + +type ProviderSubmissionStatus = SubmissionStatus + +const ( + ProviderSubmissionStatusPending = SubmissionStatusPending + ProviderSubmissionStatusSuccess = SubmissionStatusSuccess + ProviderSubmissionStatusFailed = SubmissionStatusFailed + ProviderSubmissionStatusSkipped = SubmissionStatusSkipped +) + +func submissionStatusIsTerminal(status SubmissionStatus) bool { + switch status { + case SubmissionStatusSuccess, SubmissionStatusFailed, SubmissionStatusSkipped: + return true + default: + return false + } +} + +type Payload struct { + OccurredAt time.Time + Tags map[string]string + Sections []DeliverableContentSection + Actions []DeliverableAction +} + +type DeliverableContentSection struct { + Title *string + Body string +} + +type DeliverableAction struct { + Label string + URL string +} + +type Notification struct { + Payload Payload + CreatedAt time.Time + ReadAt *time.Time + Title string + Summary string + Category Category + RelatedEntities []RelatedEntity + Submissions []ProviderSubmission + UserID uuid.UUID + ID uuid.UUID + DeliveryCompleted bool +} + +type StoredRelatedEntity struct { + Name string + Type string + NotificationID uuid.UUID + ID uuid.UUID +} + +type ProviderSubmission struct { + LastError *string + LastAttemptAt *time.Time + SucceededAt *time.Time + Provider string + Status SubmissionStatus + NotificationID uuid.UUID + ConfigurationID uuid.UUID + ID uuid.UUID + AttemptCount int +} + +type Configuration struct { + Categories *[]Category + Parameters map[string]any + Name string + Provider string + UserID uuid.UUID + ID uuid.UUID + Enabled bool +} diff --git a/core/notification/provider.go b/core/notification/provider.go new file mode 100644 index 000000000..0ff4f4986 --- /dev/null +++ b/core/notification/provider.go @@ -0,0 +1,56 @@ +package notification + +import ( + "context" + "time" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" + "dillmann.com.br/nginx-ignition/core/common/i18n" +) + +type ContentSection struct { + Title *i18n.DetachedMessage + Body i18n.DetachedMessage +} + +type Action struct { + Label i18n.DetachedMessage + URL string +} + +type RelatedEntity struct { + Type string + Name string + ID uuid.UUID +} + +type SendRequest struct { + Title i18n.DetachedMessage + Summary i18n.DetachedMessage + OccurredAt time.Time + Tags map[string]string + Category Category + Sections []ContentSection + Actions []Action + RelatedEntities []RelatedEntity +} + +type Deliverable struct { + OccurredAt time.Time + Tags map[string]string + Title string + Summary string + Category Category + Sections []DeliverableContentSection + Actions []DeliverableAction +} + +type Provider interface { + ID() string + Name(ctx context.Context) *i18n.Message + ImportantInstructions(ctx context.Context) []*i18n.Message + ConfigurationFields(ctx context.Context) []dynamicfields.DynamicField + Send(ctx context.Context, parameters map[string]any, deliverable Deliverable) error +} diff --git a/core/notification/repository.go b/core/notification/repository.go new file mode 100644 index 000000000..c0a6fafec --- /dev/null +++ b/core/notification/repository.go @@ -0,0 +1,81 @@ +package notification + +import ( + "context" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/pagination" +) + +type Repository interface { + SaveNotification( + ctx context.Context, + value *Notification, + relatedEntities []StoredRelatedEntity, + ) error + FindNotificationByIDAndUserID( + ctx context.Context, + notificationID, userID uuid.UUID, + ) (*Notification, error) + FindNotificationPage( + ctx context.Context, + userID uuid.UUID, + pageSize, pageNumber int, + searchTerms *string, + ) (*pagination.Page[Notification], error) + MarkNotificationAsRead(ctx context.Context, userID, notificationID uuid.UUID) error + MarkAllNotificationsAsRead(ctx context.Context, userID uuid.UUID) error + CountUnreadNotifications(ctx context.Context, userID uuid.UUID) (int, error) + GetLastForUserCategoryAndRelatedEntity( + ctx context.Context, + userID uuid.UUID, + category Category, + entityType string, + entityID uuid.UUID, + ) (*Notification, error) + SetDeliveryCompleted(ctx context.Context, notificationID uuid.UUID, completed bool) error + FindRelatedEntitiesByNotificationID( + ctx context.Context, + notificationID uuid.UUID, + ) ([]StoredRelatedEntity, error) + FindRelatedEntitiesByNotificationIDs( + ctx context.Context, + notificationIDs []uuid.UUID, + ) (map[uuid.UUID][]StoredRelatedEntity, error) + FindConfigurationByIDAndUserID( + ctx context.Context, + configurationID, userID uuid.UUID, + ) (*Configuration, error) + FindConfigurationsByUserID(ctx context.Context, userID uuid.UUID) ([]Configuration, error) + SaveConfiguration(ctx context.Context, value *Configuration) error + DeleteConfigurationByIDAndUserID( + ctx context.Context, + configurationID, userID uuid.UUID, + ) error + ConfigurationExistsByName( + ctx context.Context, + userID uuid.UUID, + name string, + excludeID *uuid.UUID, + ) (bool, error) + FindEnabledConfigurationsByUserID( + ctx context.Context, + userID uuid.UUID, + ) ([]Configuration, error) + SaveProviderSubmissions(ctx context.Context, submissions []ProviderSubmission) error + FindSubmissionsByNotificationID( + ctx context.Context, + notificationID uuid.UUID, + ) ([]ProviderSubmission, error) + FindSubmissionsByNotificationIDs( + ctx context.Context, + notificationIDs []uuid.UUID, + ) (map[uuid.UUID][]ProviderSubmission, error) + UpdateProviderSubmission(ctx context.Context, value *ProviderSubmission) error + FindPendingSubmissionsByNotificationID( + ctx context.Context, + notificationID uuid.UUID, + ) ([]ProviderSubmission, error) + FindNotificationsWithIncompleteDelivery(ctx context.Context) ([]Notification, error) +} diff --git a/core/notification/service.go b/core/notification/service.go new file mode 100644 index 000000000..d9b30055d --- /dev/null +++ b/core/notification/service.go @@ -0,0 +1,48 @@ +package notification + +import ( + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/user" +) + +type service struct { + repository Repository + userCommands user.Commands + i18nCommands i18n.Commands + providers func() []Provider +} + +func newService( + repository Repository, + userCommands user.Commands, + i18nCommands i18n.Commands, + providers func() []Provider, +) *service { + return &service{ + repository: repository, + userCommands: userCommands, + i18nCommands: i18nCommands, + providers: providers, + } +} + +func (s *service) findProvider(providerID string) Provider { + for _, provider := range s.providers() { + if provider.ID() == providerID { + return provider + } + } + return nil +} + +func toRelatedEntities(values []StoredRelatedEntity) []RelatedEntity { + result := make([]RelatedEntity, len(values)) + for index, value := range values { + result[index] = RelatedEntity{ + Type: value.Type, + ID: value.ID, + Name: value.Name, + } + } + return result +} diff --git a/core/notification/service_configuration.go b/core/notification/service_configuration.go new file mode 100644 index 000000000..58ce2f90f --- /dev/null +++ b/core/notification/service_configuration.go @@ -0,0 +1,130 @@ +package notification + +import ( + "context" + "sort" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" +) + +func (s *service) ListConfigurations( + ctx context.Context, + userID uuid.UUID, +) ([]Configuration, error) { + configurations, err := s.repository.FindConfigurationsByUserID(ctx, userID) + if err != nil { + return nil, err + } + + for index := range configurations { + configuration := &configurations[index] + provider := s.findProvider(configuration.Provider) + if provider != nil { + dynamicfields.RemoveSensitiveFields( + &configuration.Parameters, + provider.ConfigurationFields(ctx), + ) + } + } + + return configurations, nil +} + +func (s *service) GetConfiguration( + ctx context.Context, + userID uuid.UUID, + id uuid.UUID, +) (*Configuration, error) { + configuration, err := s.repository.FindConfigurationByIDAndUserID(ctx, id, userID) + if err != nil || configuration == nil { + return nil, err + } + + provider := s.findProvider(configuration.Provider) + if provider != nil { + dynamicfields.RemoveSensitiveFields( + &configuration.Parameters, + provider.ConfigurationFields(ctx), + ) + } + + return configuration, nil +} + +func (s *service) SaveConfiguration( + ctx context.Context, + userID uuid.UUID, + configuration *Configuration, +) error { + existing, err := s.repository.FindConfigurationByIDAndUserID( + ctx, + configuration.ID, + userID, + ) + if err != nil { + return err + } + + provider := s.findProvider(configuration.Provider) + if existing != nil { + configuration.UserID = existing.UserID + if provider != nil { + configuration.Parameters = dynamicfields.MergeSensitiveFields( + configuration.Parameters, + existing.Parameters, + provider.ConfigurationFields(ctx), + ) + } + } else { + configuration.UserID = userID + } + + if err := newValidator( + s.repository, + provider, + ).validate(ctx, userID, configuration); err != nil { + return err + } + + return s.repository.SaveConfiguration(ctx, configuration) +} + +func (s *service) DeleteConfiguration(ctx context.Context, userID, id uuid.UUID) error { + return s.repository.DeleteConfigurationByIDAndUserID(ctx, id, userID) +} + +func (s *service) GetAvailableProviders(ctx context.Context) ([]AvailableProvider, error) { + registeredProviders := s.providers() + sort.Slice(registeredProviders, func(left, right int) bool { + return registeredProviders[left].Name(ctx).String() < + registeredProviders[right].Name(ctx).String() + }) + + output := make([]AvailableProvider, len(registeredProviders)) + for index, provider := range registeredProviders { + output[index] = AvailableProvider{ + ID: provider.ID(), + Name: provider.Name(ctx), + ImportantInstructions: provider.ImportantInstructions(ctx), + ConfigurationFields: provider.ConfigurationFields(ctx), + } + } + + return output, nil +} + +func (s *service) ListCategories(ctx context.Context) ([]CategoryInfo, error) { + categories := AllCategories() + output := make([]CategoryInfo, len(categories)) + + for index, category := range categories { + output[index] = CategoryInfo{ + ID: category, + Name: CategoryName(ctx, category), + } + } + + return output, nil +} diff --git a/core/notification/service_configuration_test.go b/core/notification/service_configuration_test.go new file mode 100644 index 000000000..2684370d2 --- /dev/null +++ b/core/notification/service_configuration_test.go @@ -0,0 +1,278 @@ +package notification + +import ( + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_configurationAcceptsCategory(t *testing.T) { + category := CategoryCertificateRenewed + + t.Run("returns true when allowed is nil", func(t *testing.T) { + assert.True(t, configurationAcceptsCategory(nil, category)) + }) + + t.Run("returns false when allowed is empty", func(t *testing.T) { + assert.False(t, configurationAcceptsCategory(new([]Category{}), category)) + }) + + t.Run("returns true when category is in allow-list", func(t *testing.T) { + assert.True(t, configurationAcceptsCategory(new([]Category{ + CategoryCertificateRenewed, + CategoryNginxReloadFailed, + }), category)) + }) + + t.Run("returns false when category is not in allow-list", func(t *testing.T) { + assert.False(t, configurationAcceptsCategory( + new([]Category{CategoryNginxReloadFailed}), + category, + )) + }) +} + +func Test_service_configuration(t *testing.T) { + t.Run("SaveConfiguration", func(t *testing.T) { + t.Run("preserves owner on update", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration := newConfiguration() + configuration.ID = configurationID + configuration.UserID = uuid.New() + configuration.Name = "Updated" + + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(&Configuration{ID: configurationID, UserID: userID}, nil) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "Updated", &configurationID). + Return(false, nil) + repository.EXPECT(). + SaveConfiguration(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, saved *Configuration) error { + assert.Equal(t, userID, saved.UserID) + return nil + }) + + err := serviceInstance.SaveConfiguration(t.Context(), userID, configuration) + assert.NoError(t, err) + }) + + t.Run("merges missing sensitive parameters on update", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration := newConfiguration() + configuration.ID = configurationID + configuration.Parameters = map[string]any{"host": "smtp.example.com"} + + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(&Configuration{ + ID: configurationID, + UserID: userID, + Parameters: map[string]any{ + "host": "smtp.example.com", + "password": "stored-secret", + }, + }, nil) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, configuration.Name, &configurationID). + Return(false, nil) + repository.EXPECT(). + SaveConfiguration(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, saved *Configuration) error { + assert.Equal(t, "stored-secret", saved.Parameters["password"]) + return nil + }) + + err := serviceInstance.SaveConfiguration(t.Context(), userID, configuration) + assert.NoError(t, err) + }) + + t.Run("returns not found when configuration belongs to another user", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration := newConfiguration() + configuration.ID = configurationID + configuration.UserID = userID + configuration.Name = "Updated" + + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(nil, nil) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "Updated", &configurationID). + Return(false, nil) + repository.EXPECT(). + SaveConfiguration(t.Context(), configuration). + Return(ErrConfigurationNotFound) + + err := serviceInstance.SaveConfiguration(t.Context(), userID, configuration) + assert.ErrorIs(t, err, ErrConfigurationNotFound) + }) + + t.Run("assigns user on create", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration := newConfiguration() + configuration.ID = configurationID + configuration.Name = "Primary SMTP" + + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(nil, nil) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "Primary SMTP", &configurationID). + Return(false, nil) + repository.EXPECT(). + SaveConfiguration(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, saved *Configuration) error { + assert.Equal(t, userID, saved.UserID) + return nil + }) + + err := serviceInstance.SaveConfiguration(t.Context(), userID, configuration) + assert.NoError(t, err) + }) + + t.Run("propagates repository errors", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + expectedErr := errors.New("database error") + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration := newConfiguration() + configuration.ID = configurationID + + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(nil, expectedErr) + + err := serviceInstance.SaveConfiguration(t.Context(), userID, configuration) + assert.ErrorIs(t, err, expectedErr) + }) + }) + + t.Run("ListConfigurations", func(t *testing.T) { + t.Run("removes sensitive parameters", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + FindConfigurationsByUserID(t.Context(), userID). + Return([]Configuration{ + { + ID: configurationID, + UserID: userID, + Provider: "SMTP", + Parameters: map[string]any{ + "host": "smtp.example.com", + "password": "secret", + }, + }, + }, nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + configurations, err := serviceInstance.ListConfigurations(t.Context(), userID) + + require.NoError(t, err) + require.Len(t, configurations, 1) + assert.Equal(t, "smtp.example.com", configurations[0].Parameters["host"]) + assert.NotContains(t, configurations[0].Parameters, "password") + }) + }) + + t.Run("GetConfiguration", func(t *testing.T) { + t.Run("removes sensitive parameters", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(&Configuration{ + ID: configurationID, + UserID: userID, + Provider: "SMTP", + Parameters: map[string]any{ + "host": "smtp.example.com", + "password": "secret", + }, + }, nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration, err := serviceInstance.GetConfiguration( + t.Context(), + userID, + configurationID, + ) + + require.NoError(t, err) + require.NotNil(t, configuration) + assert.NotContains(t, configuration.Parameters, "password") + }) + + t.Run("returns nil when configuration does not exist", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(nil, nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + configuration, err := serviceInstance.GetConfiguration( + t.Context(), + userID, + configurationID, + ) + + require.NoError(t, err) + assert.Nil(t, configuration) + }) + }) +} diff --git a/core/notification/service_delivery.go b/core/notification/service_delivery.go new file mode 100644 index 000000000..7e2ed3ef9 --- /dev/null +++ b/core/notification/service_delivery.go @@ -0,0 +1,180 @@ +package notification + +import ( + "context" + "time" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/log" +) + +func (s *service) ProcessPendingDeliveries(ctx context.Context) error { + notifications, err := s.repository.FindNotificationsWithIncompleteDelivery(ctx) + if err != nil { + return err + } + + for _, notification := range notifications { + if err := s.processNotificationDeliveries(ctx, ¬ification); err != nil { + log.Errorf( + "notification delivery failed for notification %s: %s", + notification.ID, + err, + ) + } + } + + return nil +} + +func (s *service) processNotificationDeliveries( + ctx context.Context, + notification *Notification, +) error { + submissions, err := s.repository.FindPendingSubmissionsByNotificationID( + ctx, + notification.ID, + ) + if err != nil { + return err + } + + deliverable := Deliverable{ + Title: notification.Title, + Summary: notification.Summary, + Sections: notification.Payload.Sections, + Actions: notification.Payload.Actions, + OccurredAt: notification.Payload.OccurredAt, + Tags: notification.Payload.Tags, + Category: notification.Category, + } + + for index := range submissions { + if err := s.processPendingSubmission( + ctx, + notification, + &submissions[index], + deliverable, + ); err != nil { + return err + } + } + + return s.updateDeliveryCompletedIfNeeded(ctx, notification.ID) +} + +func (s *service) processPendingSubmission( + ctx context.Context, + notification *Notification, + submission *ProviderSubmission, + deliverable Deliverable, +) error { + provider := s.findProvider(submission.Provider) + if provider == nil { + submission.Status = SubmissionStatusFailed + submission.LastAttemptAt = new(time.Now()) + submission.LastError = new("provider not found") + submission.AttemptCount++ + + log.Errorf(submissionDeliveryFailureLogFormat, submission.ID, "provider not found") + if err := s.repository.UpdateProviderSubmission(ctx, submission); err != nil { + log.Errorf(submissionDeliveryFailureLogFormat, submission.ID, err) + } + + return nil + } + + configuration, err := s.repository.FindConfigurationByIDAndUserID( + ctx, + submission.ConfigurationID, + notification.UserID, + ) + if err != nil { + return err + } + + if configuration == nil { + submission.Status = SubmissionStatusFailed + submission.LastAttemptAt = new(time.Now()) + submission.LastError = new("configuration not found") + submission.AttemptCount++ + + log.Errorf(submissionDeliveryFailureLogFormat, submission.ID, "configuration not found") + if err := s.repository.UpdateProviderSubmission(ctx, submission); err != nil { + log.Errorf(submissionDeliveryFailureLogFormat, submission.ID, err) + } + + return nil + } + + if !configuration.Enabled { + submission.Status = SubmissionStatusSkipped + submission.SucceededAt = nil + submission.LastError = nil + + if err := s.repository.UpdateProviderSubmission(ctx, submission); err != nil { + log.Warnf( + "notification delivery skipped for submission %s: %s", + submission.ID, + err, + ) + } + + return nil + } + + s.attemptProviderDelivery(ctx, provider, configuration, submission, deliverable) + return nil +} + +func (s *service) attemptProviderDelivery( + ctx context.Context, + provider Provider, + configuration *Configuration, + submission *ProviderSubmission, + deliverable Deliverable, +) { + sendError := provider.Send(ctx, configuration.Parameters, deliverable) + submission.LastAttemptAt = new(time.Now()) + submission.AttemptCount++ + + if sendError == nil { + submission.Status = SubmissionStatusSuccess + submission.SucceededAt = submission.LastAttemptAt + submission.LastError = nil + } else { + submission.LastError = new(sendError.Error()) + + log.Errorf(submissionDeliveryFailureLogFormat, submission.ID, sendError) + if submission.AttemptCount >= maxDeliveryAttempts { + submission.Status = SubmissionStatusFailed + } + } + + if err := s.repository.UpdateProviderSubmission(ctx, submission); err != nil { + log.Errorf(submissionDeliveryFailureLogFormat, submission.ID, err) + } +} + +func (s *service) updateDeliveryCompletedIfNeeded( + ctx context.Context, + notificationID uuid.UUID, +) error { + submissions, err := s.repository.FindSubmissionsByNotificationID(ctx, notificationID) + if err != nil { + return err + } + + if len(submissions) == 0 { + return s.repository.SetDeliveryCompleted(ctx, notificationID, true) + } + + for _, submission := range submissions { + if !submissionStatusIsTerminal(submission.Status) { + return nil + } + } + + return s.repository.SetDeliveryCompleted(ctx, notificationID, true) +} diff --git a/core/notification/service_delivery_test.go b/core/notification/service_delivery_test.go new file mode 100644 index 000000000..19efc7e6f --- /dev/null +++ b/core/notification/service_delivery_test.go @@ -0,0 +1,356 @@ +package notification + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_service_delivery(t *testing.T) { + t.Run("ProcessPendingDeliveries", func(t *testing.T) { + t.Run("marks submission skipped when configuration is disabled", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + configurationID := uuid.New() + submissionID := uuid.New() + repository := NewMockedRepository(ctrl) + + notification := Notification{ + ID: notificationID, + UserID: userID, + } + + disabledConfiguration := &Configuration{ + ID: configurationID, + UserID: userID, + Enabled: false, + } + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{notification}, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + NotificationID: notificationID, + ConfigurationID: configurationID, + Provider: "SMTP", + Status: SubmissionStatusPending, + }, + }, nil) + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(disabledConfiguration, nil) + repository.EXPECT(). + UpdateProviderSubmission(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submission *ProviderSubmission) error { + assert.Equal(t, SubmissionStatusSkipped, submission.Status) + assert.Nil(t, submission.SucceededAt) + assert.Nil(t, submission.LastError) + assert.Equal(t, 0, submission.AttemptCount) + return nil + }) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + ConfigurationID: configurationID, + Status: SubmissionStatusSkipped, + }, + }, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), notificationID, true). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + + t.Run("continues processing when one notification fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + firstNotificationID := uuid.New() + secondNotificationID := uuid.New() + repository := NewMockedRepository(ctrl) + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{ + {ID: firstNotificationID, UserID: uuid.New()}, + {ID: secondNotificationID, UserID: uuid.New()}, + }, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), firstNotificationID). + Return(nil, assert.AnError) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), secondNotificationID). + Return(nil, nil) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), secondNotificationID). + Return(nil, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), secondNotificationID, true). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + + t.Run("marks submission successful on send", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + configurationID := uuid.New() + submissionID := uuid.New() + repository := NewMockedRepository(ctrl) + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{{ID: notificationID, UserID: userID, Title: "Certificate renewed"}}, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + NotificationID: notificationID, + ConfigurationID: configurationID, + Provider: "SMTP", + Status: SubmissionStatusPending, + }, + }, nil) + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(&Configuration{ID: configurationID, UserID: userID, Enabled: true}, nil) + repository.EXPECT(). + UpdateProviderSubmission(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submission *ProviderSubmission) error { + assert.Equal(t, SubmissionStatusSuccess, submission.Status) + require.NotNil(t, submission.SucceededAt) + assert.Equal(t, 1, submission.AttemptCount) + return nil + }) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{{ID: submissionID, Status: SubmissionStatusSuccess}}, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), notificationID, true). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + + t.Run("marks submission failed when provider is not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + submissionID := uuid.New() + repository := NewMockedRepository(ctrl) + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{{ID: notificationID, UserID: userID}}, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + NotificationID: notificationID, + Provider: "UNKNOWN", + Status: SubmissionStatusPending, + }, + }, nil) + repository.EXPECT(). + UpdateProviderSubmission(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submission *ProviderSubmission) error { + assert.Equal(t, SubmissionStatusFailed, submission.Status) + require.NotNil(t, submission.LastError) + assert.Equal(t, "provider not found", *submission.LastError) + return nil + }) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{{ID: submissionID, Status: SubmissionStatusFailed}}, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), notificationID, true). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + + t.Run("marks submission failed when configuration is not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + configurationID := uuid.New() + submissionID := uuid.New() + repository := NewMockedRepository(ctrl) + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{{ID: notificationID, UserID: userID}}, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + NotificationID: notificationID, + ConfigurationID: configurationID, + Provider: "SMTP", + Status: SubmissionStatusPending, + }, + }, nil) + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(nil, nil) + repository.EXPECT(). + UpdateProviderSubmission(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submission *ProviderSubmission) error { + assert.Equal(t, SubmissionStatusFailed, submission.Status) + require.NotNil(t, submission.LastError) + assert.Equal(t, "configuration not found", *submission.LastError) + return nil + }) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{{ID: submissionID, Status: SubmissionStatusFailed}}, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), notificationID, true). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + + t.Run("retries submission on send error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + configurationID := uuid.New() + submissionID := uuid.New() + repository := NewMockedRepository(ctrl) + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{{ID: notificationID, UserID: userID}}, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + NotificationID: notificationID, + ConfigurationID: configurationID, + Provider: "SMTP", + Status: SubmissionStatusPending, + }, + }, nil) + enabledConfiguration := &Configuration{ + ID: configurationID, + UserID: userID, + Enabled: true, + } + + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(enabledConfiguration, nil) + repository.EXPECT(). + UpdateProviderSubmission(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submission *ProviderSubmission) error { + assert.Equal(t, SubmissionStatusPending, submission.Status) + require.NotNil(t, submission.LastError) + assert.Equal(t, errSendFailed.Error(), *submission.LastError) + assert.Equal(t, 1, submission.AttemptCount) + return nil + }) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{{ + ID: submissionID, + ConfigurationID: configurationID, + Status: SubmissionStatusPending, + }}, nil) + + serviceInstance := newService(repository, nil, nil, failingTestProviders(errSendFailed)) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + + t.Run("marks submission failed at max attempts", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + configurationID := uuid.New() + submissionID := uuid.New() + repository := NewMockedRepository(ctrl) + + repository.EXPECT(). + FindNotificationsWithIncompleteDelivery(t.Context()). + Return([]Notification{{ID: notificationID, UserID: userID}}, nil) + repository.EXPECT(). + FindPendingSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{ + { + ID: submissionID, + NotificationID: notificationID, + ConfigurationID: configurationID, + Provider: "SMTP", + Status: SubmissionStatusPending, + AttemptCount: maxDeliveryAttempts - 1, + }, + }, nil) + repository.EXPECT(). + FindConfigurationByIDAndUserID(t.Context(), configurationID, userID). + Return(&Configuration{ID: configurationID, UserID: userID, Enabled: true}, nil) + repository.EXPECT(). + UpdateProviderSubmission(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submission *ProviderSubmission) error { + assert.Equal(t, SubmissionStatusFailed, submission.Status) + assert.Equal(t, maxDeliveryAttempts, submission.AttemptCount) + return nil + }) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{{ID: submissionID, Status: SubmissionStatusFailed}}, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), notificationID, true). + Return(nil) + + serviceInstance := newService(repository, nil, nil, failingTestProviders(errSendFailed)) + + err := serviceInstance.ProcessPendingDeliveries(t.Context()) + assert.NoError(t, err) + }) + }) +} diff --git a/core/notification/service_inbox.go b/core/notification/service_inbox.go new file mode 100644 index 000000000..b809efa23 --- /dev/null +++ b/core/notification/service_inbox.go @@ -0,0 +1,124 @@ +package notification + +import ( + "context" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/pagination" +) + +func (s *service) ListNotifications( + ctx context.Context, + userID uuid.UUID, + pageSize, pageNumber int, + searchTerms *string, +) (*pagination.Page[Notification], error) { + page, err := s.repository.FindNotificationPage(ctx, userID, pageSize, pageNumber, searchTerms) + if err != nil { + return nil, err + } + + if page == nil || len(page.Contents) == 0 { + return page, nil + } + + notificationIDs := make([]uuid.UUID, len(page.Contents)) + for index, item := range page.Contents { + notificationIDs[index] = item.ID + } + + relatedEntitiesByNotificationID, err := s.repository.FindRelatedEntitiesByNotificationIDs( + ctx, + notificationIDs, + ) + if err != nil { + return nil, err + } + + submissionsByNotificationID, err := s.repository.FindSubmissionsByNotificationIDs( + ctx, + notificationIDs, + ) + if err != nil { + return nil, err + } + + for index := range page.Contents { + notification := &page.Contents[index] + notification.RelatedEntities = toRelatedEntities( + relatedEntitiesByNotificationID[notification.ID], + ) + notification.Submissions = submissionsByNotificationID[notification.ID] + } + + return page, nil +} + +func (s *service) GetNotification( + ctx context.Context, + userID uuid.UUID, + id uuid.UUID, +) (*Notification, error) { + notification, err := s.repository.FindNotificationByIDAndUserID(ctx, id, userID) + if err != nil || notification == nil { + return nil, err + } + + return s.loadNotificationDetails(ctx, notification) +} + +func (s *service) MarkAsRead(ctx context.Context, userID, id uuid.UUID) error { + return s.repository.MarkNotificationAsRead(ctx, userID, id) +} + +func (s *service) MarkAllAsRead(ctx context.Context, userID uuid.UUID) error { + return s.repository.MarkAllNotificationsAsRead(ctx, userID) +} + +func (s *service) UnreadCount(ctx context.Context, userID uuid.UUID) (int, error) { + return s.repository.CountUnreadNotifications(ctx, userID) +} + +func (s *service) GetLastForUserCategoryAndRelatedEntity( + ctx context.Context, + userID uuid.UUID, + category Category, + entityType string, + entityID uuid.UUID, +) (*Notification, error) { + notification, err := s.repository.GetLastForUserCategoryAndRelatedEntity( + ctx, + userID, + category, + entityType, + entityID, + ) + if err != nil || notification == nil { + return nil, err + } + + return s.loadNotificationDetails(ctx, notification) +} + +func (s *service) loadNotificationDetails( + ctx context.Context, + notification *Notification, +) (*Notification, error) { + relatedEntities, err := s.repository.FindRelatedEntitiesByNotificationID( + ctx, + notification.ID, + ) + if err != nil { + return nil, err + } + + notification.RelatedEntities = toRelatedEntities(relatedEntities) + submissions, err := s.repository.FindSubmissionsByNotificationID(ctx, notification.ID) + if err != nil { + return nil, err + } + + notification.Submissions = submissions + return notification, nil +} diff --git a/core/notification/service_inbox_test.go b/core/notification/service_inbox_test.go new file mode 100644 index 000000000..9cb6e3884 --- /dev/null +++ b/core/notification/service_inbox_test.go @@ -0,0 +1,241 @@ +package notification + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/core/common/pagination" +) + +func Test_service_inbox(t *testing.T) { + t.Run("ListNotifications", func(t *testing.T) { + t.Run("enriches page with related entities and submissions", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + entityID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + page := pagination.New(0, 10, 1, []Notification{{ID: notificationID, UserID: userID}}) + repository.EXPECT(). + FindNotificationPage(t.Context(), userID, 10, 0, (*string)(nil)). + Return(page, nil) + repository.EXPECT(). + FindRelatedEntitiesByNotificationIDs(t.Context(), []uuid.UUID{notificationID}). + Return(map[uuid.UUID][]StoredRelatedEntity{ + notificationID: {{Type: "certificate", ID: entityID, Name: "example.com"}}, + }, nil) + repository.EXPECT(). + FindSubmissionsByNotificationIDs(t.Context(), []uuid.UUID{notificationID}). + Return(map[uuid.UUID][]ProviderSubmission{ + notificationID: {{Provider: "SMTP", Status: SubmissionStatusSuccess}}, + }, nil) + + result, err := serviceInstance.ListNotifications(t.Context(), userID, 10, 0, nil) + + require.NoError(t, err) + require.Len(t, result.Contents, 1) + require.Len(t, result.Contents[0].RelatedEntities, 1) + assert.Equal(t, entityID, result.Contents[0].RelatedEntities[0].ID) + require.Len(t, result.Contents[0].Submissions, 1) + assert.Equal(t, "SMTP", result.Contents[0].Submissions[0].Provider) + }) + + t.Run("returns empty page without enrichment queries", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + page := pagination.New(0, 10, 0, []Notification{}) + repository.EXPECT(). + FindNotificationPage(t.Context(), userID, 10, 0, (*string)(nil)). + Return(page, nil) + + result, err := serviceInstance.ListNotifications(t.Context(), userID, 10, 0, nil) + + require.NoError(t, err) + assert.Empty(t, result.Contents) + }) + }) + + t.Run("GetNotification", func(t *testing.T) { + t.Run("returns nil when notification does not exist", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + repository.EXPECT(). + FindNotificationByIDAndUserID(t.Context(), notificationID, userID). + Return(nil, nil) + + notification, err := serviceInstance.GetNotification( + t.Context(), + userID, + notificationID, + ) + + require.NoError(t, err) + assert.Nil(t, notification) + }) + + t.Run("loads related entities and submissions", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + entityID := uuid.New() + repository := NewMockedRepository(ctrl) + serviceInstance := newService(repository, nil, nil, testProviders) + + expected := &Notification{ID: notificationID, UserID: userID} + repository.EXPECT(). + FindNotificationByIDAndUserID(t.Context(), notificationID, userID). + Return(expected, nil) + repository.EXPECT(). + FindRelatedEntitiesByNotificationID(t.Context(), notificationID). + Return([]StoredRelatedEntity{{Type: "certificate", ID: entityID}}, nil) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return([]ProviderSubmission{{Provider: "SMTP"}}, nil) + + notification, err := serviceInstance.GetNotification( + t.Context(), + userID, + notificationID, + ) + + require.NoError(t, err) + require.Len(t, notification.RelatedEntities, 1) + require.Len(t, notification.Submissions, 1) + }) + }) + + t.Run("MarkAsRead", func(t *testing.T) { + t.Run("marks notification as read", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + notificationID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + MarkNotificationAsRead(t.Context(), userID, notificationID). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.MarkAsRead(t.Context(), userID, notificationID) + assert.NoError(t, err) + }) + }) + + t.Run("MarkAllAsRead", func(t *testing.T) { + t.Run("marks all notifications as read", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + MarkAllNotificationsAsRead(t.Context(), userID). + Return(nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + err := serviceInstance.MarkAllAsRead(t.Context(), userID) + assert.NoError(t, err) + }) + }) + + t.Run("UnreadCount", func(t *testing.T) { + t.Run("returns unread count", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + CountUnreadNotifications(t.Context(), userID). + Return(4, nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + count, err := serviceInstance.UnreadCount(t.Context(), userID) + + require.NoError(t, err) + assert.Equal(t, 4, count) + }) + }) + + t.Run("GetLastForUserCategoryAndRelatedEntity", func(t *testing.T) { + t.Run("returns notification with related entities", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + entityID := uuid.New() + notificationID := uuid.New() + repository := NewMockedRepository(ctrl) + + expected := &Notification{ + ID: notificationID, + UserID: userID, + Category: CategoryCertificateExpiring, + } + relatedEntities := []StoredRelatedEntity{ + { + NotificationID: notificationID, + Type: "certificate", + ID: entityID, + Name: "example.com", + }, + } + + repository.EXPECT(). + GetLastForUserCategoryAndRelatedEntity( + t.Context(), + userID, + CategoryCertificateExpiring, + "certificate", + entityID, + ). + Return(expected, nil) + repository.EXPECT(). + FindRelatedEntitiesByNotificationID(t.Context(), notificationID). + Return(relatedEntities, nil) + repository.EXPECT(). + FindSubmissionsByNotificationID(t.Context(), notificationID). + Return(nil, nil) + + serviceInstance := newService(repository, nil, nil, testProviders) + + notification, err := serviceInstance.GetLastForUserCategoryAndRelatedEntity( + t.Context(), + userID, + CategoryCertificateExpiring, + "certificate", + entityID, + ) + + require.NoError(t, err) + require.NotNil(t, notification) + require.Len(t, notification.RelatedEntities, 1) + assert.Equal(t, entityID, notification.RelatedEntities[0].ID) + }) + }) +} diff --git a/core/notification/service_publish.go b/core/notification/service_publish.go new file mode 100644 index 000000000..64e9a7f94 --- /dev/null +++ b/core/notification/service_publish.go @@ -0,0 +1,210 @@ +package notification + +import ( + "context" + "time" + + "github.com/google/uuid" + "golang.org/x/text/language" + + "dillmann.com.br/nginx-ignition/core/common/coreerror" + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/common/log" +) + +func (s *service) Publish( + ctx context.Context, + userID uuid.UUID, + request SendRequest, +) (*Notification, error) { + return s.publishForUser(ctx, userID, request) +} + +func (s *service) Broadcast(ctx context.Context, request SendRequest) error { + userIDs, err := s.userCommands.ListEnabledIDs(ctx) + if err != nil { + return err + } + + for _, userID := range userIDs { + if _, err := s.publishForUser(ctx, userID, request); err != nil { + log.Errorf("notification broadcast failed for user %s: %s", userID, err) + } + } + + return nil +} + +func (s *service) publishForUser( + ctx context.Context, + userID uuid.UUID, + request SendRequest, +) (*Notification, error) { + if !isValidCategory(request.Category) { + return nil, coreerror.New( + i18n.M(ctx, i18n.K.CoreNotificationInvalidCategory), + true, + ) + } + + usr, err := s.userCommands.Get(ctx, userID) + if err != nil { + return nil, err + } + + if usr == nil { + return nil, coreerror.New(i18n.M(ctx, i18n.K.CoreUserNotFound), false) + } + + notificationLanguage := usr.NotificationLanguage + lang := resolveLanguage(s.i18nCommands, notificationLanguage) + deliverable := resolveSendRequest(s.i18nCommands, lang, request) + notification := notificationFromDeliverable(userID, deliverable) + + relatedEntities := make([]StoredRelatedEntity, len(request.RelatedEntities)) + for index, entity := range request.RelatedEntities { + relatedEntities[index] = StoredRelatedEntity{ + NotificationID: notification.ID, + Type: entity.Type, + ID: entity.ID, + Name: entity.Name, + } + } + + if saveErr := s.repository.SaveNotification( + ctx, + notification, + relatedEntities, + ); saveErr != nil { + return nil, saveErr + } + + configurations, err := s.repository.FindEnabledConfigurationsByUserID(ctx, userID) + if err != nil { + return nil, err + } + + submissions := make([]ProviderSubmission, 0) + for _, configuration := range configurations { + if !configurationAcceptsCategory(configuration.Categories, request.Category) { + continue + } + + submissions = append(submissions, ProviderSubmission{ + ID: uuid.New(), + NotificationID: notification.ID, + ConfigurationID: configuration.ID, + Provider: configuration.Provider, + Status: SubmissionStatusPending, + AttemptCount: 0, + }) + } + + if len(submissions) > 0 { + if err := s.repository.SaveProviderSubmissions(ctx, submissions); err != nil { + return nil, err + } + } else { + if err := s.repository.SetDeliveryCompleted(ctx, notification.ID, true); err != nil { + return nil, err + } + notification.DeliveryCompleted = true + } + + notification.RelatedEntities = toRelatedEntities(relatedEntities) + return notification, nil +} + +func resolveSendRequest( + commands i18n.Commands, + lang language.Tag, + request SendRequest, +) Deliverable { + sections := make([]DeliverableContentSection, len(request.Sections)) + for index, section := range request.Sections { + resolved := DeliverableContentSection{ + Body: translateDetached(commands, lang, section.Body), + } + if section.Title != nil { + resolved.Title = new(translateDetached(commands, lang, *section.Title)) + } + sections[index] = resolved + } + + actions := make([]DeliverableAction, len(request.Actions)) + for index, action := range request.Actions { + actions[index] = DeliverableAction{ + Label: translateDetached(commands, lang, action.Label), + URL: action.URL, + } + } + + tags := request.Tags + if tags == nil { + tags = map[string]string{} + } + + return Deliverable{ + Title: translateDetached(commands, lang, request.Title), + Summary: translateDetached(commands, lang, request.Summary), + Sections: sections, + Actions: actions, + OccurredAt: request.OccurredAt, + Tags: tags, + Category: request.Category, + } +} + +func translateDetached( + commands i18n.Commands, + lang language.Tag, + detached i18n.DetachedMessage, +) string { + variables := detached.Variables + if variables == nil { + variables = map[string]any{} + } + + return commands.Translate(lang, detached.Key, variables) +} + +func resolveLanguage(commands i18n.Commands, languageValue string) language.Tag { + if languageValue == "" { + return commands.DefaultLanguage() + } + + tag, err := language.Parse(languageValue) + if err != nil || !commands.Supports(tag) { + return commands.DefaultLanguage() + } + + return tag +} + +func notificationFromDeliverable(userID uuid.UUID, deliverable Deliverable) *Notification { + now := time.Now() + occurredAt := deliverable.OccurredAt + if occurredAt.IsZero() { + occurredAt = now + } + + tags := deliverable.Tags + if tags == nil { + tags = map[string]string{} + } + + return &Notification{ + ID: uuid.New(), + UserID: userID, + Title: deliverable.Title, + Summary: deliverable.Summary, + Category: deliverable.Category, + CreatedAt: now, + Payload: Payload{ + Sections: deliverable.Sections, + Actions: deliverable.Actions, + OccurredAt: occurredAt, + Tags: tags, + }, + } +} diff --git a/core/notification/service_publish_test.go b/core/notification/service_publish_test.go new file mode 100644 index 000000000..4e8eee541 --- /dev/null +++ b/core/notification/service_publish_test.go @@ -0,0 +1,379 @@ +package notification + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/user" +) + +func Test_resolveSendRequest(t *testing.T) { + t.Run("resolves all deliverable fields", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + commands := i18n.NewMockedCommands(ctrl) + lang := language.AmericanEnglish + + request := SendRequest{ + Title: i18n.DetachedMessage{ + Key: "title-key", + Variables: map[string]any{"domain": "example.com"}, + }, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + Sections: []ContentSection{ + { + Title: new(i18n.DetachedMessage{ + Key: "section-title-key", + Variables: map[string]any{"name": "host"}, + }), + Body: i18n.DetachedMessage{Key: "body-key"}, + }, + }, + Actions: []Action{ + { + Label: i18n.DetachedMessage{Key: "action-label"}, + URL: "https://example.com", + }, + }, + OccurredAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC), + Tags: map[string]string{"reminder_days": "30"}, + Category: CategoryCertificateExpiring, + } + + commands.EXPECT(). + Translate(lang, "title-key", map[string]any{"domain": "example.com"}). + Return("Title resolved") + commands.EXPECT(). + Translate(lang, "summary-key", map[string]any{}). + Return("Summary resolved") + commands.EXPECT(). + Translate(lang, "section-title-key", map[string]any{"name": "host"}). + Return("Section title resolved") + commands.EXPECT(). + Translate(lang, "body-key", map[string]any{}). + Return("Body resolved") + commands.EXPECT(). + Translate(lang, "action-label", map[string]any{}). + Return("Action label resolved") + + deliverable := resolveSendRequest(commands, lang, request) + + assert.Equal(t, "Title resolved", deliverable.Title) + assert.Equal(t, "Summary resolved", deliverable.Summary) + require.Len(t, deliverable.Sections, 1) + require.NotNil(t, deliverable.Sections[0].Title) + assert.Equal(t, "Section title resolved", *deliverable.Sections[0].Title) + assert.Equal(t, "Body resolved", deliverable.Sections[0].Body) + require.Len(t, deliverable.Actions, 1) + assert.Equal(t, "Action label resolved", deliverable.Actions[0].Label) + assert.Equal(t, "https://example.com", deliverable.Actions[0].URL) + assert.Equal(t, request.OccurredAt, deliverable.OccurredAt) + assert.Equal(t, request.Tags, deliverable.Tags) + assert.Equal(t, CategoryCertificateExpiring, deliverable.Category) + }) +} + +func Test_resolveLanguage(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + commands := i18n.NewMockedCommands(ctrl) + defaultLanguage := language.AmericanEnglish + commands.EXPECT().DefaultLanguage().Return(defaultLanguage).AnyTimes() + commands.EXPECT().Supports(gomock.Any()).DoAndReturn(func(tag language.Tag) bool { + return tag != language.Make("xx") + }).AnyTimes() + + t.Run("uses default language when value is empty", func(t *testing.T) { + assert.Equal(t, defaultLanguage, resolveLanguage(commands, "")) + }) + + t.Run("uses parsed language when supported", func(t *testing.T) { + tag, _ := language.Parse("pt") + assert.Equal(t, tag, resolveLanguage(commands, "pt")) + }) + + t.Run("falls back to default when unsupported", func(t *testing.T) { + assert.Equal(t, defaultLanguage, resolveLanguage(commands, "xx")) + }) +} + +func Test_service_publish(t *testing.T) { + t.Run("Publish", func(t *testing.T) { + t.Run("persists notification and marks delivery completed", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + certificateID := uuid.New() + repository := NewMockedRepository(ctrl) + userCommands := user.NewMockedCommands(ctrl) + commands := i18n.NewMockedCommands(ctrl) + lang := language.AmericanEnglish + + userCommands.EXPECT(). + Get(t.Context(), userID). + Return(&user.User{NotificationLanguage: "en"}, nil) + commands.EXPECT().DefaultLanguage().Return(lang).AnyTimes() + commands.EXPECT().Supports(gomock.Any()).Return(true).AnyTimes() + commands.EXPECT().Translate(gomock.Any(), "title-key", gomock.Any()).Return("Title") + commands.EXPECT().Translate(gomock.Any(), "summary-key", gomock.Any()).Return("Summary") + + repository.EXPECT(). + SaveNotification(t.Context(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, notification *Notification, relatedEntities []StoredRelatedEntity) error { + assert.Equal(t, userID, notification.UserID) + assert.Equal(t, "Title", notification.Title) + assert.Equal(t, "Summary", notification.Summary) + assert.Equal(t, CategoryCertificateExpiring, notification.Category) + require.Len(t, relatedEntities, 1) + assert.Equal(t, "certificate", relatedEntities[0].Type) + assert.Equal(t, certificateID, relatedEntities[0].ID) + return nil + }) + + repository.EXPECT(). + FindEnabledConfigurationsByUserID(t.Context(), userID). + Return([]Configuration{}, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), gomock.Any(), true). + Return(nil) + + serviceInstance := newService( + repository, + userCommands, + commands, + func() []Provider { return nil }, + ) + + notification, err := serviceInstance.Publish(t.Context(), userID, SendRequest{ + Title: i18n.DetachedMessage{Key: "title-key"}, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + RelatedEntities: []RelatedEntity{ + {Type: "certificate", ID: certificateID, Name: "example.com"}, + }, + OccurredAt: time.Now(), + Category: CategoryCertificateExpiring, + }) + + require.NoError(t, err) + require.NotNil(t, notification) + assert.True(t, notification.DeliveryCompleted) + }) + + t.Run("returns error for invalid category", func(t *testing.T) { + serviceInstance := newService(nil, nil, nil, testProviders) + + notification, err := serviceInstance.Publish(t.Context(), uuid.New(), SendRequest{ + Title: i18n.DetachedMessage{Key: "title-key"}, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + OccurredAt: time.Now(), + Category: Category("INVALID"), + }) + + assert.Error(t, err) + assert.Nil(t, notification) + }) + + t.Run("returns error when user does not exist", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + userCommands := user.NewMockedCommands(ctrl) + userCommands.EXPECT().Get(t.Context(), userID).Return(nil, nil) + + serviceInstance := newService(nil, userCommands, nil, testProviders) + + notification, err := serviceInstance.Publish(t.Context(), userID, SendRequest{ + Title: i18n.DetachedMessage{Key: "title-key"}, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + OccurredAt: time.Now(), + Category: CategoryCertificateExpiring, + }) + + assert.Error(t, err) + assert.Nil(t, notification) + }) + + t.Run("uses user notification language", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + userCommands := user.NewMockedCommands(ctrl) + commands := i18n.NewMockedCommands(ctrl) + portuguese, _ := language.Parse("pt") + + userCommands.EXPECT(). + Get(t.Context(), userID). + Return(&user.User{NotificationLanguage: "pt"}, nil) + commands.EXPECT().DefaultLanguage().Return(language.AmericanEnglish).AnyTimes() + commands.EXPECT().Supports(gomock.Any()).Return(true).AnyTimes() + commands.EXPECT(). + Translate(portuguese, "title-key", gomock.Any()). + Return("Título") + commands.EXPECT(). + Translate(portuguese, "summary-key", gomock.Any()). + Return("Resumo") + + repository.EXPECT().SaveNotification(t.Context(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, notification *Notification, _ []StoredRelatedEntity) error { + assert.Equal(t, "Título", notification.Title) + assert.Equal(t, "Resumo", notification.Summary) + return nil + }) + repository.EXPECT(). + FindEnabledConfigurationsByUserID(t.Context(), userID). + Return(nil, nil) + repository.EXPECT().SetDeliveryCompleted(t.Context(), gomock.Any(), true).Return(nil) + + serviceInstance := newService(repository, userCommands, commands, testProviders) + + notification, err := serviceInstance.Publish(t.Context(), userID, SendRequest{ + Title: i18n.DetachedMessage{Key: "title-key"}, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + OccurredAt: time.Now(), + Category: CategoryCertificateExpiring, + }) + + require.NoError(t, err) + require.NotNil(t, notification) + }) + + t.Run("creates submissions for matching configurations", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + configurationID := uuid.New() + repository := NewMockedRepository(ctrl) + userCommands := user.NewMockedCommands(ctrl) + commands := i18n.NewMockedCommands(ctrl) + lang := language.AmericanEnglish + userCommands.EXPECT(). + Get(t.Context(), userID). + Return(&user.User{NotificationLanguage: "en"}, nil) + commands.EXPECT().DefaultLanguage().Return(lang).AnyTimes() + commands.EXPECT().Supports(gomock.Any()).Return(true).AnyTimes() + commands.EXPECT().Translate(gomock.Any(), "title-key", gomock.Any()).Return("Title") + commands.EXPECT().Translate(gomock.Any(), "summary-key", gomock.Any()).Return("Summary") + + repository.EXPECT(). + SaveNotification(t.Context(), gomock.Any(), gomock.Any()). + Return(nil) + repository.EXPECT(). + FindEnabledConfigurationsByUserID(t.Context(), userID). + Return([]Configuration{ + { + ID: configurationID, + Provider: "SMTP", + Enabled: true, + Categories: new([]Category{CategoryCertificateExpiring}), + }, + { + ID: uuid.New(), + Provider: "SMTP", + Enabled: true, + Categories: new([]Category{CategoryNginxReloadFailed}), + }, + }, nil) + repository.EXPECT(). + SaveProviderSubmissions(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, submissions []ProviderSubmission) error { + require.Len(t, submissions, 1) + assert.Equal(t, configurationID, submissions[0].ConfigurationID) + assert.Equal(t, SubmissionStatusPending, submissions[0].Status) + return nil + }) + + serviceInstance := newService(repository, userCommands, commands, testProviders) + + notification, err := serviceInstance.Publish(t.Context(), userID, SendRequest{ + Title: i18n.DetachedMessage{Key: "title-key"}, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + OccurredAt: time.Now(), + Category: CategoryCertificateExpiring, + }) + + require.NoError(t, err) + require.NotNil(t, notification) + assert.False(t, notification.DeliveryCompleted) + }) + }) + + t.Run("Broadcast", func(t *testing.T) { + t.Run("publishes to all enabled users", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + firstUserID := uuid.New() + secondUserID := uuid.New() + repository := NewMockedRepository(ctrl) + userCommands := user.NewMockedCommands(ctrl) + commands := i18n.NewMockedCommands(ctrl) + lang := language.AmericanEnglish + + userCommands.EXPECT(). + ListEnabledIDs(t.Context()). + Return([]uuid.UUID{firstUserID, secondUserID}, nil) + userCommands.EXPECT(). + Get(t.Context(), firstUserID). + Return(&user.User{NotificationLanguage: "en"}, nil) + userCommands.EXPECT(). + Get(t.Context(), secondUserID). + Return(&user.User{NotificationLanguage: "pt"}, nil) + + commands.EXPECT().DefaultLanguage().Return(lang).AnyTimes() + commands.EXPECT().Supports(gomock.Any()).Return(true).AnyTimes() + commands.EXPECT(). + Translate(gomock.Any(), "title-key", gomock.Any()). + Return("Title"). + Times(2) + commands.EXPECT(). + Translate(gomock.Any(), "summary-key", gomock.Any()). + Return("Summary"). + Times(2) + + repository.EXPECT(). + SaveNotification(t.Context(), gomock.Any(), gomock.Any()). + Return(nil). + Times(2) + repository.EXPECT(). + FindEnabledConfigurationsByUserID(t.Context(), firstUserID). + Return(nil, nil) + repository.EXPECT(). + FindEnabledConfigurationsByUserID(t.Context(), secondUserID). + Return(nil, nil) + repository.EXPECT(). + SetDeliveryCompleted(t.Context(), gomock.Any(), true). + Return(nil). + Times(2) + + serviceInstance := newService( + repository, + userCommands, + commands, + func() []Provider { return nil }, + ) + + err := serviceInstance.Broadcast(t.Context(), SendRequest{ + Title: i18n.DetachedMessage{Key: "title-key"}, + Summary: i18n.DetachedMessage{Key: "summary-key"}, + OccurredAt: time.Now(), + Category: CategoryNginxReloadSucceeded, + }) + + assert.NoError(t, err) + }) + }) +} diff --git a/core/notification/validator.go b/core/notification/validator.go new file mode 100644 index 000000000..1d0b2bd35 --- /dev/null +++ b/core/notification/validator.go @@ -0,0 +1,93 @@ +package notification + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/common/validation" +) + +type validator struct { + repository Repository + provider Provider + delegate *validation.ConsistencyValidator +} + +func newValidator(repository Repository, provider Provider) *validator { + return &validator{ + repository: repository, + provider: provider, + delegate: validation.NewValidator(), + } +} + +func (v *validator) validate( + ctx context.Context, + userID uuid.UUID, + configuration *Configuration, +) error { + if strings.TrimSpace(configuration.Name) == "" { + v.delegate.Add("name", i18n.M(ctx, i18n.K.CommonValueMissing)) + } else { + exists, err := v.repository.ConfigurationExistsByName( + ctx, + userID, + configuration.Name, + &configuration.ID, + ) + if err != nil { + return err + } + + if exists { + v.delegate.Add("name", i18n.M(ctx, i18n.K.CoreNotificationDuplicatedName)) + } + } + + if strings.TrimSpace(configuration.Provider) == "" { + v.delegate.Add("provider", i18n.M(ctx, i18n.K.CommonValueMissing)) + } else if v.provider == nil { + v.delegate.Add("provider", i18n.M(ctx, i18n.K.CommonInvalidValue)) + } + + v.validateCategories(ctx, configuration.Categories) + + params := configuration.Parameters + if params == nil { + params = map[string]any{} + } + + if v.provider != nil { + if err := dynamicfields.Validate( + ctx, + v.provider.ConfigurationFields(ctx), + params, + ); err != nil { + for _, violation := range err.Violations { + v.delegate.Add(violation.Path, violation.Message) + } + } + } + + return v.delegate.Result() +} + +func (v *validator) validateCategories(ctx context.Context, categories *[]Category) { + if categories == nil { + return + } + + for index, category := range *categories { + if !isValidCategory(category) { + v.delegate.Add( + fmt.Sprintf("categories[%d]", index), + i18n.M(ctx, i18n.K.CommonInvalidValue), + ) + } + } +} diff --git a/core/notification/validator_test.go b/core/notification/validator_test.go new file mode 100644 index 000000000..8fdb38073 --- /dev/null +++ b/core/notification/validator_test.go @@ -0,0 +1,227 @@ +package notification + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/common/validation" +) + +func Test_validator(t *testing.T) { + t.Run("validate", func(t *testing.T) { + t.Run("valid configuration passes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + assert.NoError(t, err) + }) + + t.Run("empty name fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + configuration := newConfiguration() + configuration.Name = "" + + err := newValidator(NewMockedRepository(ctrl), testProvider{}). + validate(t.Context(), uuid.New(), configuration) + + assert.Error(t, err) + }) + + t.Run("duplicate name fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + configuration := newConfiguration() + + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, configuration.Name, &configuration.ID). + Return(true, nil) + + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + assert.Error(t, err) + }) + + t.Run("empty provider fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Provider = "" + + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + assert.Error(t, err) + }) + + t.Run("invalid provider fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Provider = "UNKNOWN" + + err := newValidator(repository, nil).validate(t.Context(), userID, configuration) + + assert.Error(t, err) + }) + + t.Run("nil categories passes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Categories = nil + + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + assert.NoError(t, err) + }) + + t.Run("valid categories passes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Categories = new([]Category{ + CategoryCertificateRenewed, + CategoryNginxReloadFailed, + }) + + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + assert.NoError(t, err) + }) + + t.Run("invalid category fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Categories = new([]Category{Category("INVALID")}) + + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + assert.Error(t, err) + }) + + t.Run("invalid category reports field violation", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Categories = new([]Category{Category("INVALID")}) + + err := newValidator( + repository, + testProvider{}, + ).validate(t.Context(), userID, configuration) + + var consistencyErr *validation.ConsistencyError + if assert.ErrorAs(t, err, &consistencyErr) { + require.Len(t, consistencyErr.Violations, 1) + assert.Equal(t, "categories[0]", consistencyErr.Violations[0].Path) + assert.Contains( + t, + consistencyErr.Violations[0].Message.Key, + i18n.K.CommonInvalidValue, + ) + } + }) + + t.Run("missing required parameter fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + userID := uuid.New() + repository := NewMockedRepository(ctrl) + repository.EXPECT(). + ConfigurationExistsByName(t.Context(), userID, "test", gomock.Any()). + Return(false, nil) + + configuration := newConfiguration() + configuration.Parameters = map[string]any{} + + err := newValidator(repository, requiredHostProvider{}). + validate(t.Context(), userID, configuration) + + var consistencyErr *validation.ConsistencyError + if assert.ErrorAs(t, err, &consistencyErr) { + require.Len(t, consistencyErr.Violations, 1) + assert.Equal(t, "parameters.host", consistencyErr.Violations[0].Path) + } + }) + }) +} diff --git a/core/user/artifacts_test.go b/core/user/artifacts_test.go index af11d503f..4bba597a8 100644 --- a/core/user/artifacts_test.go +++ b/core/user/artifacts_test.go @@ -2,14 +2,37 @@ package user import ( "github.com/google/uuid" + "go.uber.org/mock/gomock" + + "dillmann.com.br/nginx-ignition/core/common/configuration" + "dillmann.com.br/nginx-ignition/core/common/i18n" ) +func newTestCommands( + ctrl *gomock.Controller, + repository Repository, + cfg *configuration.Configuration, +) (*service, Commands) { + i18nCommands := i18n.NewMockedCommands(ctrl) + i18nCommands.EXPECT().Supports(gomock.Any()).Return(true).AnyTimes() + + return newCommands(repository, cfg, i18nCommands) +} + +func newTestValidator(ctrl *gomock.Controller, repository Repository) *validator { + i18nCommands := i18n.NewMockedCommands(ctrl) + i18nCommands.EXPECT().Supports(gomock.Any()).Return(true).AnyTimes() + + return newValidator(repository, i18nCommands) +} + func newUser() *User { return &User{ - ID: uuid.New(), - Username: "testuser", - Name: "Test User", - Enabled: true, + ID: uuid.New(), + Username: "testuser", + Name: "Test User", + NotificationLanguage: "en", + Enabled: true, Permissions: Permissions{ Hosts: NoAccessAccessLevel, Streams: NoAccessAccessLevel, @@ -30,12 +53,13 @@ func newUser() *User { func newSaveRequest() *SaveRequest { return &SaveRequest{ - ID: uuid.New(), - Username: "testuser", - Name: "Test User", - Enabled: true, - Password: new("password123"), - RemoveTOTP: false, + ID: uuid.New(), + Username: "testuser", + Name: "Test User", + NotificationLanguage: "en", + Enabled: true, + Password: new("password123"), + RemoveTOTP: false, Permissions: Permissions{ Hosts: NoAccessAccessLevel, Streams: NoAccessAccessLevel, diff --git a/core/user/commands.go b/core/user/commands.go index 4cccf2004..49f8fca43 100644 --- a/core/user/commands.go +++ b/core/user/commands.go @@ -22,9 +22,14 @@ type Commands interface { pageSize, pageNumber int, searchTerms *string, ) (*pagination.Page[User], error) + ListEnabledIDs(ctx context.Context) ([]uuid.UUID, error) Save(ctx context.Context, user *SaveRequest, currentUserID *uuid.UUID) error UpdatePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error - UpdateProfile(ctx context.Context, id uuid.UUID, name, username string) error + UpdateProfile( + ctx context.Context, + id uuid.UUID, + name, username, notificationLanguage string, + ) error OnboardingCompleted(ctx context.Context) (bool, error) GetTOTPStatus(ctx context.Context, id uuid.UUID) (bool, error) DisableTOTP(ctx context.Context, id uuid.UUID) error diff --git a/core/user/installer.go b/core/user/installer.go index 513b6572c..928d4a4bf 100644 --- a/core/user/installer.go +++ b/core/user/installer.go @@ -3,6 +3,7 @@ package user import ( "dillmann.com.br/nginx-ignition/core/common/configuration" "dillmann.com.br/nginx-ignition/core/common/container" + "dillmann.com.br/nginx-ignition/core/common/i18n" ) func Install() error { @@ -17,7 +18,8 @@ func Install() error { func newCommands( repository Repository, cfg *configuration.Configuration, + i18nCommands i18n.Commands, ) (*service, Commands) { - serviceInstance := newService(repository, cfg) + serviceInstance := newService(repository, cfg, i18nCommands) return serviceInstance, serviceInstance } diff --git a/core/user/model.go b/core/user/model.go index 9fb47d8ed..8a9094e85 100644 --- a/core/user/model.go +++ b/core/user/model.go @@ -3,13 +3,14 @@ package user import "github.com/google/uuid" type SaveRequest struct { - Password *string - Permissions Permissions - Name string - Username string - ID uuid.UUID - Enabled bool - RemoveTOTP bool + Password *string + Permissions Permissions + Name string + Username string + NotificationLanguage string + ID uuid.UUID + Enabled bool + RemoveTOTP bool } type AccessLevel string @@ -21,14 +22,15 @@ const ( ) type User struct { - Permissions Permissions - Name string - Username string - PasswordHash string - PasswordSalt string - TOTP TOTP - ID uuid.UUID - Enabled bool + Permissions Permissions + Name string + Username string + NotificationLanguage string + PasswordHash string + PasswordSalt string + TOTP TOTP + ID uuid.UUID + Enabled bool } type TOTP struct { diff --git a/core/user/repository.go b/core/user/repository.go index 4a519df07..fb75de8a5 100644 --- a/core/user/repository.go +++ b/core/user/repository.go @@ -19,6 +19,7 @@ type Repository interface { searchTerms *string, ) (*pagination.Page[User], error) IsEnabledByID(ctx context.Context, id uuid.UUID) (bool, error) + ListEnabledIDs(ctx context.Context) ([]uuid.UUID, error) Count(ctx context.Context) (int, error) TryUpdateLastUsedTOTPCode(ctx context.Context, id uuid.UUID, code string) (bool, error) } diff --git a/core/user/service.go b/core/user/service.go index aaac42269..26194655f 100644 --- a/core/user/service.go +++ b/core/user/service.go @@ -18,12 +18,18 @@ import ( type service struct { repository Repository configuration *configuration.Configuration + i18nCommands i18n.Commands } -func newService(repository Repository, cfg *configuration.Configuration) *service { +func newService( + repository Repository, + cfg *configuration.Configuration, + i18nCommands i18n.Commands, +) *service { return &service{ repository: repository, configuration: cfg, + i18nCommands: i18nCommands, } } @@ -132,7 +138,7 @@ func (s *service) UpdatePassword( func (s *service) UpdateProfile( ctx context.Context, id uuid.UUID, - name, username string, + name, username, notificationLanguage string, ) error { databaseState, err := s.repository.FindByID(ctx, id) if err != nil { @@ -144,25 +150,27 @@ func (s *service) UpdateProfile( } request := &SaveRequest{ - ID: id, - Name: name, - Username: username, - Enabled: databaseState.Enabled, - Permissions: databaseState.Permissions, + ID: id, + Name: name, + Username: username, + NotificationLanguage: notificationLanguage, + Enabled: databaseState.Enabled, + Permissions: databaseState.Permissions, } updatedState := &User{ - ID: id, - Name: name, - Username: username, - Enabled: databaseState.Enabled, - PasswordHash: databaseState.PasswordHash, - PasswordSalt: databaseState.PasswordSalt, - Permissions: databaseState.Permissions, - TOTP: databaseState.TOTP, - } - - if err := newValidator(s.repository).validate( + ID: id, + Name: name, + Username: username, + NotificationLanguage: notificationLanguage, + Enabled: databaseState.Enabled, + PasswordHash: databaseState.PasswordHash, + PasswordSalt: databaseState.PasswordSalt, + Permissions: databaseState.Permissions, + TOTP: databaseState.TOTP, + } + + if err := newValidator(s.repository, s.i18nCommands).validate( ctx, updatedState, databaseState, @@ -224,17 +232,18 @@ func (s *service) Save(ctx context.Context, request *SaveRequest, currentUserID } updatedState := &User{ - ID: request.ID, - Enabled: request.Enabled, - Name: request.Name, - Username: request.Username, - PasswordHash: passwordHash, - PasswordSalt: passwordSalt, - Permissions: request.Permissions, - TOTP: totpValue, - } - - if err := newValidator(s.repository).validate( + ID: request.ID, + Enabled: request.Enabled, + Name: request.Name, + Username: request.Username, + NotificationLanguage: request.NotificationLanguage, + PasswordHash: passwordHash, + PasswordSalt: passwordSalt, + Permissions: request.Permissions, + TOTP: totpValue, + } + + if err := newValidator(s.repository, s.i18nCommands).validate( ctx, updatedState, databaseState, @@ -259,6 +268,10 @@ func (s *service) List( return s.repository.FindPage(ctx, pageSize, pageNumber, searchTerms) } +func (s *service) ListEnabledIDs(ctx context.Context) ([]uuid.UUID, error) { + return s.repository.ListEnabledIDs(ctx) +} + func (s *service) GetTOTPStatus(ctx context.Context, id uuid.UUID) (bool, error) { usr, err := s.repository.FindByID(ctx, id) if err != nil { diff --git a/core/user/service_test.go b/core/user/service_test.go index dd6220611..c54089955 100644 --- a/core/user/service_test.go +++ b/core/user/service_test.go @@ -30,7 +30,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByID(t.Context(), expected.ID).Return(expected, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) result, err := svc.Get(t.Context(), expected.ID) assert.NoError(t, err) @@ -48,7 +48,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByID(t.Context(), id).Return(nil, expectedErr) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) result, err := svc.Get(t.Context(), id) assert.Error(t, err) @@ -68,7 +68,7 @@ func Test_service(t *testing.T) { repo.EXPECT().DeleteByID(t.Context(), id).Return(nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) err := svc.Delete(t.Context(), id) assert.NoError(t, err) @@ -87,7 +87,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindPage(t.Context(), 10, 1, &searchTerms).Return(expectedPage, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) result, err := svc.List(t.Context(), 10, 1, &searchTerms) assert.NoError(t, err) @@ -106,7 +106,7 @@ func Test_service(t *testing.T) { repo.EXPECT().Count(t.Context()).Return(expectedCount, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) count, err := svc.GetCount(t.Context()) assert.NoError(t, err) @@ -123,7 +123,7 @@ func Test_service(t *testing.T) { repo.EXPECT().Count(t.Context()).Return(1, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) completed, err := svc.OnboardingCompleted(t.Context()) assert.NoError(t, err) @@ -138,7 +138,7 @@ func Test_service(t *testing.T) { repo.EXPECT().Count(t.Context()).Return(0, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) completed, err := svc.OnboardingCompleted(t.Context()) assert.NoError(t, err) @@ -158,12 +158,35 @@ func Test_service(t *testing.T) { repo.EXPECT().Save(t.Context(), gomock.Any()).Return(nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) err := svc.Save(t.Context(), request, nil) assert.NoError(t, err) }) + t.Run("fails when notification language is omitted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + usr.NotificationLanguage = "pt" + + request := newSaveRequest() + request.ID = usr.ID + request.Password = nil + request.NotificationLanguage = "" + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByID(t.Context(), request.ID).Return(usr, nil) + repo.EXPECT().FindByUsername(t.Context(), request.Username).Return(usr, nil) + + cfg := &configuration.Configuration{} + svc, _ := newTestCommands(ctrl, repo, cfg) + err := svc.Save(t.Context(), request, nil) + + assert.Error(t, err) + }) + t.Run("removes TOTP when requested", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -186,7 +209,7 @@ func Test_service(t *testing.T) { }) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) err := svc.Save(t.Context(), request, nil) assert.NoError(t, err) @@ -206,7 +229,7 @@ func Test_service(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "nonexistent").Return(nil, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), "nonexistent", "password", "") require.Error(t, err) @@ -230,7 +253,7 @@ func Test_service(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), usr.Username, password, "") assert.NoError(t, err) @@ -249,7 +272,7 @@ func Test_service(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), usr.Username, "wrongpassword", "") require.Error(t, err) @@ -273,7 +296,7 @@ func Test_service(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), usr.Username, password, "") assert.NoError(t, err) @@ -293,7 +316,7 @@ func Test_service(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), usr.Username, password, "000000") assert.NoError(t, err) @@ -317,7 +340,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) repo.EXPECT().TryUpdateLastUsedTOTPCode(t.Context(), usr.ID, code).Return(true, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), usr.Username, password, code) assert.NoError(t, err) @@ -341,7 +364,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) repo.EXPECT().TryUpdateLastUsedTOTPCode(t.Context(), usr.ID, code).Return(false, nil) - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) outcome, result, err := svc.Authenticate(t.Context(), usr.Username, password, code) assert.NoError(t, err) @@ -367,14 +390,21 @@ func Test_service(t *testing.T) { DoAndReturn(func(_ any, updated *User) error { assert.Equal(t, newName, updated.Name) assert.Equal(t, newUsername, updated.Username) + assert.Equal(t, usr.NotificationLanguage, updated.NotificationLanguage) assert.Equal(t, usr.PasswordHash, updated.PasswordHash) assert.Equal(t, usr.Permissions, updated.Permissions) return nil }) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) - err := svc.UpdateProfile(t.Context(), usr.ID, newName, newUsername) + svc, _ := newTestCommands(ctrl, repo, cfg) + err := svc.UpdateProfile( + t.Context(), + usr.ID, + newName, + newUsername, + usr.NotificationLanguage, + ) assert.NoError(t, err) }) @@ -391,12 +421,43 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByUsername(t.Context(), "takenuser").Return(otherUser, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) - err := svc.UpdateProfile(t.Context(), usr.ID, "Updated Name", "takenuser") + svc, _ := newTestCommands(ctrl, repo, cfg) + err := svc.UpdateProfile( + t.Context(), + usr.ID, + "Updated Name", + "takenuser", + usr.NotificationLanguage, + ) assert.Error(t, err) }) + t.Run("sets notification language from request", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + usr.NotificationLanguage = "en" + newLanguage := "pt-BR" + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) + repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) + repo.EXPECT(). + Save(t.Context(), gomock.Any()). + DoAndReturn(func(_ any, updated *User) error { + assert.Equal(t, newLanguage, updated.NotificationLanguage) + return nil + }) + + cfg := &configuration.Configuration{} + svc, _ := newTestCommands(ctrl, repo, cfg) + err := svc.UpdateProfile(t.Context(), usr.ID, usr.Name, usr.Username, newLanguage) + + assert.NoError(t, err) + }) + t.Run("fails when name is too short", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -408,8 +469,50 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) - err := svc.UpdateProfile(t.Context(), usr.ID, "ab", usr.Username) + svc, _ := newTestCommands(ctrl, repo, cfg) + err := svc.UpdateProfile( + t.Context(), + usr.ID, + "ab", + usr.Username, + usr.NotificationLanguage, + ) + + assert.Error(t, err) + }) + + t.Run("fails when notification language is invalid", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) + repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) + + cfg := &configuration.Configuration{} + svc, _ := newTestCommands(ctrl, repo, cfg) + err := svc.UpdateProfile(t.Context(), usr.ID, usr.Name, usr.Username, "invalid!!!") + + assert.Error(t, err) + }) + + t.Run("fails when notification language is unsupported", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + i18nCommands := i18n.NewMockedCommands(ctrl) + i18nCommands.EXPECT().Supports(gomock.Any()).Return(false) + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) + repo.EXPECT().FindByUsername(t.Context(), usr.Username).Return(usr, nil) + + cfg := &configuration.Configuration{} + svc, _ := newCommands(repo, cfg, i18nCommands) + err := svc.UpdateProfile(t.Context(), usr.ID, usr.Name, usr.Username, "fr") assert.Error(t, err) }) @@ -426,7 +529,7 @@ func Test_service(t *testing.T) { repo.EXPECT().IsEnabledByID(t.Context(), id).Return(true, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) enabled, err := svc.GetStatus(t.Context(), id) assert.NoError(t, err) @@ -446,7 +549,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) status, err := svc.GetTOTPStatus(t.Context(), usr.ID) assert.NoError(t, err) @@ -464,7 +567,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) status, err := svc.GetTOTPStatus(t.Context(), usr.ID) assert.NoError(t, err) @@ -482,7 +585,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) status, err := svc.GetTOTPStatus(t.Context(), usr.ID) assert.NoError(t, err) @@ -507,7 +610,7 @@ func Test_service(t *testing.T) { }) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) err := svc.DisableTOTP(t.Context(), usr.ID) assert.NoError(t, err) @@ -530,7 +633,7 @@ func Test_service(t *testing.T) { }) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) url, err := svc.EnableTOTP(t.Context(), usr.ID) assert.NoError(t, err) @@ -560,7 +663,7 @@ func Test_service(t *testing.T) { repo.EXPECT().TryUpdateLastUsedTOTPCode(t.Context(), usr.ID, code).Return(true, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) ok, err := svc.ActivateTOTP(t.Context(), usr.ID, code) assert.NoError(t, err) @@ -578,7 +681,7 @@ func Test_service(t *testing.T) { repo.EXPECT().FindByID(t.Context(), usr.ID).Return(usr, nil) cfg := &configuration.Configuration{} - svc, _ := newCommands(repo, cfg) + svc, _ := newTestCommands(ctrl, repo, cfg) ok, err := svc.ActivateTOTP(t.Context(), usr.ID, "000000") assert.NoError(t, err) diff --git a/core/user/validator.go b/core/user/validator.go index dd6c14972..269573525 100644 --- a/core/user/validator.go +++ b/core/user/validator.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/google/uuid" + "golang.org/x/text/language" "dillmann.com.br/nginx-ignition/core/common/i18n" "dillmann.com.br/nginx-ignition/core/common/validation" @@ -17,8 +18,9 @@ const ( ) type validator struct { - delegate *validation.ConsistencyValidator - repository Repository + delegate *validation.ConsistencyValidator + repository Repository + i18nCommands i18n.Commands } func (v *validator) validate( @@ -64,10 +66,19 @@ func (v *validator) validate( } v.validatePermissions(ctx, request.Permissions) + v.validateNotificationLanguage(ctx, request.NotificationLanguage) return v.delegate.Result() } +func (v *validator) validateNotificationLanguage(ctx context.Context, notificationLanguage string) { + tag, err := language.Parse(notificationLanguage) + + if err != nil || !v.i18nCommands.Supports(tag) { + v.delegate.Add("notificationLanguage", i18n.M(ctx, i18n.K.CommonInvalidValue)) + } +} + func (v *validator) validatePermissions(ctx context.Context, permissions Permissions) { v.validatePermission(ctx, "hosts", permissions.Hosts) v.validatePermission(ctx, "streams", permissions.Streams) @@ -117,9 +128,10 @@ func (v *validator) validatePermission(ctx context.Context, key string, value Ac } } -func newValidator(repository Repository) *validator { +func newValidator(repository Repository, i18nCommands i18n.Commands) *validator { return &validator{ - delegate: validation.NewValidator(), - repository: repository, + delegate: validation.NewValidator(), + repository: repository, + i18nCommands: i18nCommands, } } diff --git a/core/user/validator_test.go b/core/user/validator_test.go index 00c1773f2..589537475 100644 --- a/core/user/validator_test.go +++ b/core/user/validator_test.go @@ -6,6 +6,9 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "dillmann.com.br/nginx-ignition/core/common/i18n" ) func Test_validator(t *testing.T) { @@ -19,7 +22,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -37,7 +40,7 @@ func Test_validator(t *testing.T) { currentUser := &User{ID: usr.ID} repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, currentUser, request, new(usr.ID)) @@ -54,7 +57,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -72,7 +75,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(currentUser, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, currentUser, request, nil) @@ -89,7 +92,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(&User{ID: otherID}, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -107,7 +110,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "ab").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -125,7 +128,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -142,7 +145,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -160,7 +163,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -178,7 +181,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -196,7 +199,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -214,7 +217,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -232,7 +235,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -250,7 +253,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -268,7 +271,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -286,7 +289,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -304,7 +307,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -322,7 +325,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -340,7 +343,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -358,7 +361,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -376,7 +379,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -394,7 +397,7 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) @@ -412,7 +415,85 @@ func Test_validator(t *testing.T) { repo := NewMockedRepository(ctrl) repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) - userValidator := newValidator(repo) + userValidator := newTestValidator(ctrl, repo) + + err := userValidator.validate(t.Context(), usr, nil, request, nil) + + assert.Error(t, err) + }) + + t.Run("supported notification language passes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + request := newSaveRequest() + request.NotificationLanguage = "en" + + i18nCommands := i18n.NewMockedCommands(ctrl) + i18nCommands.EXPECT(). + Supports(language.English). + Return(true) + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) + userValidator := newValidator(repo, i18nCommands) + + err := userValidator.validate(t.Context(), usr, nil, request, nil) + + assert.NoError(t, err) + }) + + t.Run("invalid notification language tag fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + request := newSaveRequest() + request.NotificationLanguage = "@@@" + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) + userValidator := newValidator(repo, i18n.NewMockedCommands(ctrl)) + + err := userValidator.validate(t.Context(), usr, nil, request, nil) + + assert.Error(t, err) + }) + + t.Run("unsupported notification language fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + request := newSaveRequest() + request.NotificationLanguage = "fr" + + i18nCommands := i18n.NewMockedCommands(ctrl) + i18nCommands.EXPECT(). + Supports(language.French). + Return(false) + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) + userValidator := newValidator(repo, i18nCommands) + + err := userValidator.validate(t.Context(), usr, nil, request, nil) + + assert.Error(t, err) + }) + + t.Run("empty notification language fails validation", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usr := newUser() + request := newSaveRequest() + request.NotificationLanguage = "" + + repo := NewMockedRepository(ctrl) + repo.EXPECT().FindByUsername(t.Context(), "testuser").Return(nil, nil) + userValidator := newTestValidator(ctrl, repo) err := userValidator.validate(t.Context(), usr, nil, request, nil) diff --git a/database/common/migrations/scripts/postgres/032_notification.up.sql b/database/common/migrations/scripts/postgres/032_notification.up.sql new file mode 100644 index 000000000..0067cc8dd --- /dev/null +++ b/database/common/migrations/scripts/postgres/032_notification.up.sql @@ -0,0 +1,67 @@ +alter table "user" add column notification_language varchar(16) not null default 'en'; + +create table notification ( + id uuid not null, + user_id uuid not null, + title text not null, + summary text not null, + category varchar(64) not null, + payload text not null, + read_at timestamp, + created_at timestamp not null, + delivery_completed boolean not null, + constraint pk_notification primary key (id), + constraint fk_notification_user foreign key (user_id) references "user" (id) +); + +create index idx_notification_user_id on notification (user_id); +create index idx_notification_category on notification (category); +create index idx_notification_created_at on notification (created_at); +create index idx_notification_read_at on notification (read_at); +create index idx_notification_delivery_completed on notification (delivery_completed); + +create table notification_configuration ( + id uuid not null, + user_id uuid not null, + name varchar(256) not null, + provider varchar(64) not null, + enabled boolean not null, + parameters text not null, + categories text, + constraint pk_notification_configuration primary key (id), + constraint fk_notification_configuration_user foreign key (user_id) references "user" (id), + constraint uq_notification_configuration_user_name unique (user_id, name) +); + +create table notification_provider_submission ( + id uuid not null, + notification_id uuid not null, + configuration_id uuid not null, + provider varchar(64) not null, + status varchar(32) not null, + attempt_count integer not null, + last_error text, + last_attempt_at timestamp, + succeeded_at timestamp, + constraint pk_notification_provider_submission primary key (id), + constraint fk_notification_provider_submission_notification + foreign key (notification_id) references notification (id) on delete cascade, + constraint fk_notification_provider_submission_configuration + foreign key (configuration_id) references notification_configuration (id) on delete cascade +); + +create index idx_notification_provider_submission_status on notification_provider_submission (status); + +create table notification_related_entity ( + notification_id uuid not null, + entity_type varchar(64) not null, + entity_id uuid not null, + name varchar(256), + constraint fk_notification_related_entity_notification + foreign key (notification_id) references notification (id) on delete cascade +); + +create index idx_notification_related_entity_entity + on notification_related_entity (entity_type, entity_id, notification_id); +create index idx_notification_related_entity_notification_id + on notification_related_entity (notification_id); diff --git a/database/common/migrations/scripts/sqlite/032_notification.up.sql b/database/common/migrations/scripts/sqlite/032_notification.up.sql new file mode 100644 index 000000000..0067cc8dd --- /dev/null +++ b/database/common/migrations/scripts/sqlite/032_notification.up.sql @@ -0,0 +1,67 @@ +alter table "user" add column notification_language varchar(16) not null default 'en'; + +create table notification ( + id uuid not null, + user_id uuid not null, + title text not null, + summary text not null, + category varchar(64) not null, + payload text not null, + read_at timestamp, + created_at timestamp not null, + delivery_completed boolean not null, + constraint pk_notification primary key (id), + constraint fk_notification_user foreign key (user_id) references "user" (id) +); + +create index idx_notification_user_id on notification (user_id); +create index idx_notification_category on notification (category); +create index idx_notification_created_at on notification (created_at); +create index idx_notification_read_at on notification (read_at); +create index idx_notification_delivery_completed on notification (delivery_completed); + +create table notification_configuration ( + id uuid not null, + user_id uuid not null, + name varchar(256) not null, + provider varchar(64) not null, + enabled boolean not null, + parameters text not null, + categories text, + constraint pk_notification_configuration primary key (id), + constraint fk_notification_configuration_user foreign key (user_id) references "user" (id), + constraint uq_notification_configuration_user_name unique (user_id, name) +); + +create table notification_provider_submission ( + id uuid not null, + notification_id uuid not null, + configuration_id uuid not null, + provider varchar(64) not null, + status varchar(32) not null, + attempt_count integer not null, + last_error text, + last_attempt_at timestamp, + succeeded_at timestamp, + constraint pk_notification_provider_submission primary key (id), + constraint fk_notification_provider_submission_notification + foreign key (notification_id) references notification (id) on delete cascade, + constraint fk_notification_provider_submission_configuration + foreign key (configuration_id) references notification_configuration (id) on delete cascade +); + +create index idx_notification_provider_submission_status on notification_provider_submission (status); + +create table notification_related_entity ( + notification_id uuid not null, + entity_type varchar(64) not null, + entity_id uuid not null, + name varchar(256), + constraint fk_notification_related_entity_notification + foreign key (notification_id) references notification (id) on delete cascade +); + +create index idx_notification_related_entity_entity + on notification_related_entity (entity_type, entity_id, notification_id); +create index idx_notification_related_entity_notification_id + on notification_related_entity (notification_id); diff --git a/database/go.mod b/database/go.mod index 650a2fe2d..22f74b932 100644 --- a/database/go.mod +++ b/database/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/database -go 1.26.3 +go 1.26.4 require ( github.com/JCoupalK/go-pgdump v1.1.1-0.20251117080142-ba155b05e5d3 diff --git a/database/installer.go b/database/installer.go index 7072f9a10..0d1e6ee27 100644 --- a/database/installer.go +++ b/database/installer.go @@ -10,6 +10,7 @@ import ( "dillmann.com.br/nginx-ignition/database/common/migrations" "dillmann.com.br/nginx-ignition/database/host" "dillmann.com.br/nginx-ignition/database/integration" + "dillmann.com.br/nginx-ignition/database/notification" "dillmann.com.br/nginx-ignition/database/settings" "dillmann.com.br/nginx-ignition/database/stream" "dillmann.com.br/nginx-ignition/database/user" @@ -35,5 +36,6 @@ func Install() error { stream.New, backup.New, vpn.New, + notification.New, ) } diff --git a/database/notification/artifacts_test.go b/database/notification/artifacts_test.go new file mode 100644 index 000000000..d4adbb825 --- /dev/null +++ b/database/notification/artifacts_test.go @@ -0,0 +1,44 @@ +package notification + +import ( + "time" + + "github.com/google/uuid" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func newNotification(userID uuid.UUID) *notification.Notification { + return ¬ification.Notification{ + ID: uuid.New(), + UserID: userID, + Title: "Certificate renewed", + Summary: "Certificate example.com was renewed", + Category: notification.CategoryCertificateRenewed, + CreatedAt: time.Now().UTC().Truncate(time.Second), + Payload: notification.Payload{ + Sections: []notification.DeliverableContentSection{ + {Body: "Renewal completed successfully."}, + }, + Actions: []notification.DeliverableAction{ + {Label: "View certificate", URL: "/certificates"}, + }, + OccurredAt: time.Now().UTC().Truncate(time.Second), + Tags: map[string]string{"domain": "example.com"}, + }, + DeliveryCompleted: false, + } +} + +func newConfiguration(userID uuid.UUID) *notification.Configuration { + return ¬ification.Configuration{ + ID: uuid.New(), + UserID: userID, + Name: uuid.NewString(), + Provider: "SMTP", + Enabled: true, + Parameters: map[string]any{ + "host": "smtp.example.com", + }, + } +} diff --git a/database/notification/converter.go b/database/notification/converter.go new file mode 100644 index 000000000..c44bb5720 --- /dev/null +++ b/database/notification/converter.go @@ -0,0 +1,174 @@ +package notification + +import ( + "encoding/json" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func notificationToDomain(model *notificationModel) (*notification.Notification, error) { + payload := notification.Payload{} + err := json.Unmarshal([]byte(model.Payload), &payload) + if err != nil { + return nil, err + } + + return ¬ification.Notification{ + ID: model.ID, + UserID: model.UserID, + Title: model.Title, + Summary: model.Summary, + Category: notification.Category(model.Category), + Payload: payload, + ReadAt: model.ReadAt, + CreatedAt: model.CreatedAt, + DeliveryCompleted: model.DeliveryCompleted, + }, nil +} + +func notificationToModel(domain *notification.Notification) (*notificationModel, error) { + payload, err := json.Marshal(domain.Payload) + if err != nil { + return nil, err + } + + return ¬ificationModel{ + ID: domain.ID, + UserID: domain.UserID, + Title: domain.Title, + Summary: domain.Summary, + Category: string(domain.Category), + Payload: string(payload), + ReadAt: domain.ReadAt, + CreatedAt: domain.CreatedAt, + DeliveryCompleted: domain.DeliveryCompleted, + }, nil +} + +func configurationToDomain(model *configurationModel) (*notification.Configuration, error) { + parameters := make(map[string]any) + err := json.Unmarshal([]byte(model.Parameters), ¶meters) + if err != nil { + return nil, err + } + + categories, err := categoriesFromDatabase(model.Categories) + if err != nil { + return nil, err + } + + return ¬ification.Configuration{ + ID: model.ID, + UserID: model.UserID, + Name: model.Name, + Provider: model.Provider, + Enabled: model.Enabled, + Parameters: parameters, + Categories: categories, + }, nil +} + +func configurationToModel(domain *notification.Configuration) (*configurationModel, error) { + parameters, err := json.Marshal(domain.Parameters) + if err != nil { + return nil, err + } + + categories, err := categoriesToDatabase(domain.Categories) + if err != nil { + return nil, err + } + + return &configurationModel{ + ID: domain.ID, + UserID: domain.UserID, + Name: domain.Name, + Provider: domain.Provider, + Enabled: domain.Enabled, + Parameters: string(parameters), + Categories: categories, + }, nil +} + +func categoriesToDatabase(categories *[]notification.Category) (*string, error) { + if categories == nil { + return nil, nil + } + + encoded, err := json.Marshal(categories) + if err != nil { + return nil, err + } + + return new(string(encoded)), nil +} + +func categoriesFromDatabase(value *string) (*[]notification.Category, error) { + if value == nil { + return nil, nil + } + + categories := make([]notification.Category, 0) + err := json.Unmarshal([]byte(*value), &categories) + if err != nil { + return nil, err + } + + return new(categories), nil +} + +func submissionToDomain(model *providerSubmissionModel) *notification.ProviderSubmission { + return ¬ification.ProviderSubmission{ + ID: model.ID, + NotificationID: model.NotificationID, + ConfigurationID: model.ConfigurationID, + Provider: model.Provider, + Status: notification.SubmissionStatus(model.Status), + AttemptCount: model.AttemptCount, + LastError: model.LastError, + LastAttemptAt: model.LastAttemptAt, + SucceededAt: model.SucceededAt, + } +} + +func submissionToModel(domain *notification.ProviderSubmission) *providerSubmissionModel { + return &providerSubmissionModel{ + ID: domain.ID, + NotificationID: domain.NotificationID, + ConfigurationID: domain.ConfigurationID, + Provider: domain.Provider, + Status: string(domain.Status), + AttemptCount: domain.AttemptCount, + LastError: domain.LastError, + LastAttemptAt: domain.LastAttemptAt, + SucceededAt: domain.SucceededAt, + } +} + +func relatedEntityToDomain(model *relatedEntityModel) notification.StoredRelatedEntity { + name := "" + if model.Name != nil { + name = *model.Name + } + + return notification.StoredRelatedEntity{ + NotificationID: model.NotificationID, + Type: model.EntityType, + ID: model.EntityID, + Name: name, + } +} + +func relatedEntityToModel(domain notification.StoredRelatedEntity) relatedEntityModel { + var name *string + if domain.Name != "" { + name = new(domain.Name) + } + + return relatedEntityModel{ + NotificationID: domain.NotificationID, + EntityType: domain.Type, + EntityID: domain.ID, + Name: name, + } +} diff --git a/database/notification/converter_test.go b/database/notification/converter_test.go new file mode 100644 index 000000000..5d97680a6 --- /dev/null +++ b/database/notification/converter_test.go @@ -0,0 +1,428 @@ +package notification + +import ( + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func Test_Converter(t *testing.T) { + t.Run("notificationToDomain", func(t *testing.T) { + t.Run("successfully converts a complete model to domain", func(t *testing.T) { + readAt := time.Now().UTC().Truncate(time.Second) + createdAt := readAt.Add(-time.Hour) + payload := notification.Payload{ + OccurredAt: createdAt, + Tags: map[string]string{"domain": "example.com"}, + Sections: []notification.DeliverableContentSection{ + {Body: "Renewal completed successfully."}, + }, + Actions: []notification.DeliverableAction{ + {Label: "View certificate", URL: "/certificates"}, + }, + } + payloadJSON, err := json.Marshal(payload) + require.NoError(t, err) + + model := ¬ificationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Title: "Certificate renewed", + Summary: "Certificate example.com was renewed", + Category: string(notification.CategoryCertificateRenewed), + Payload: string(payloadJSON), + ReadAt: &readAt, + CreatedAt: createdAt, + DeliveryCompleted: true, + } + + domain, err := notificationToDomain(model) + + require.NoError(t, err) + assert.Equal(t, model.ID, domain.ID) + assert.Equal(t, model.UserID, domain.UserID) + assert.Equal(t, model.Title, domain.Title) + assert.Equal(t, model.Summary, domain.Summary) + assert.Equal(t, notification.CategoryCertificateRenewed, domain.Category) + assert.Equal(t, payload, domain.Payload) + assert.Equal(t, model.ReadAt, domain.ReadAt) + assert.Equal(t, model.CreatedAt, domain.CreatedAt) + assert.True(t, domain.DeliveryCompleted) + }) + + t.Run("returns error when payload is invalid JSON", func(t *testing.T) { + model := ¬ificationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Payload: "{invalid", + } + + domain, err := notificationToDomain(model) + + assert.Nil(t, domain) + assert.Error(t, err) + }) + }) + + t.Run("notificationToModel", func(t *testing.T) { + t.Run("successfully converts a complete domain to model", func(t *testing.T) { + domain := newNotification(uuid.New()) + domain.ReadAt = new(time.Now().UTC().Truncate(time.Second)) + domain.DeliveryCompleted = true + + model, err := notificationToModel(domain) + + require.NoError(t, err) + assert.Equal(t, domain.ID, model.ID) + assert.Equal(t, domain.UserID, model.UserID) + assert.Equal(t, domain.Title, model.Title) + assert.Equal(t, domain.Summary, model.Summary) + assert.Equal(t, string(domain.Category), model.Category) + assert.Equal(t, domain.ReadAt, model.ReadAt) + assert.Equal(t, domain.CreatedAt, model.CreatedAt) + assert.True(t, model.DeliveryCompleted) + + roundTrip, err := notificationToDomain(model) + require.NoError(t, err) + assert.Equal(t, domain.Payload, roundTrip.Payload) + }) + }) + + t.Run("configurationToDomain", func(t *testing.T) { + t.Run("successfully converts a complete model to domain", func(t *testing.T) { + parametersJSON, err := json.Marshal(map[string]any{ + "host": "smtp.example.com", + "port": 587, + }) + require.NoError(t, err) + categoriesJSON := `["CERTIFICATE_RENEWED","CERTIFICATE_EXPIRING"]` + + model := &configurationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "Email alerts", + Provider: "SMTP", + Enabled: true, + Parameters: string(parametersJSON), + Categories: &categoriesJSON, + } + + domain, err := configurationToDomain(model) + + require.NoError(t, err) + assert.Equal(t, model.ID, domain.ID) + assert.Equal(t, model.UserID, domain.UserID) + assert.Equal(t, model.Name, domain.Name) + assert.Equal(t, model.Provider, domain.Provider) + assert.Equal(t, model.Enabled, domain.Enabled) + assert.Equal(t, "smtp.example.com", domain.Parameters["host"]) + assert.Equal(t, float64(587), domain.Parameters["port"]) + require.NotNil(t, domain.Categories) + assert.Equal(t, []notification.Category{ + notification.CategoryCertificateRenewed, + notification.CategoryCertificateExpiring, + }, *domain.Categories) + }) + + t.Run("maps nil categories to nil", func(t *testing.T) { + model := &configurationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "All categories", + Provider: "SMTP", + Parameters: `{}`, + } + + domain, err := configurationToDomain(model) + + require.NoError(t, err) + assert.Nil(t, domain.Categories) + }) + + t.Run("maps empty categories JSON to empty slice", func(t *testing.T) { + categoriesJSON := `[]` + model := &configurationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "No categories", + Provider: "SMTP", + Parameters: `{}`, + Categories: &categoriesJSON, + } + + domain, err := configurationToDomain(model) + + require.NoError(t, err) + require.NotNil(t, domain.Categories) + assert.Empty(t, *domain.Categories) + }) + + t.Run("returns error when parameters are invalid JSON", func(t *testing.T) { + model := &configurationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Parameters: "{invalid", + } + + domain, err := configurationToDomain(model) + + assert.Nil(t, domain) + assert.Error(t, err) + }) + + t.Run("returns error when categories are invalid JSON", func(t *testing.T) { + categoriesJSON := `{invalid` + model := &configurationModel{ + ID: uuid.New(), + UserID: uuid.New(), + Parameters: `{}`, + Categories: &categoriesJSON, + } + + domain, err := configurationToDomain(model) + + assert.Nil(t, domain) + assert.Error(t, err) + }) + }) + + t.Run("configurationToModel", func(t *testing.T) { + t.Run("successfully converts a complete domain to model", func(t *testing.T) { + categories := []notification.Category{ + notification.CategoryNginxReloadFailed, + notification.CategoryNginxReloadSucceeded, + } + domain := ¬ification.Configuration{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "Nginx alerts", + Provider: "SMTP", + Enabled: false, + Parameters: map[string]any{ + "from": "alerts@example.com", + }, + Categories: &categories, + } + + model, err := configurationToModel(domain) + + require.NoError(t, err) + assert.Equal(t, domain.ID, model.ID) + assert.Equal(t, domain.UserID, model.UserID) + assert.Equal(t, domain.Name, model.Name) + assert.Equal(t, domain.Provider, model.Provider) + assert.False(t, model.Enabled) + require.NotNil(t, model.Categories) + + roundTrip, err := configurationToDomain(model) + require.NoError(t, err) + assert.Equal(t, domain.Parameters["from"], roundTrip.Parameters["from"]) + assert.Equal(t, domain.Categories, roundTrip.Categories) + }) + + t.Run("maps nil categories to nil", func(t *testing.T) { + domain := newConfiguration(uuid.New()) + + model, err := configurationToModel(domain) + + require.NoError(t, err) + assert.Nil(t, model.Categories) + }) + + t.Run("maps empty categories slice to empty JSON array", func(t *testing.T) { + categories := []notification.Category{} + domain := ¬ification.Configuration{ + ID: uuid.New(), + UserID: uuid.New(), + Name: "Disabled categories", + Provider: "SMTP", + Parameters: map[string]any{}, + Categories: &categories, + } + + model, err := configurationToModel(domain) + + require.NoError(t, err) + require.NotNil(t, model.Categories) + assert.Equal(t, `[]`, *model.Categories) + }) + }) + + t.Run("submissionToDomain", func(t *testing.T) { + t.Run("successfully converts a complete model to domain", func(t *testing.T) { + lastAttemptAt := time.Now().UTC().Truncate(time.Second) + succeededAt := lastAttemptAt.Add(time.Minute) + lastError := "connection refused" + + model := &providerSubmissionModel{ + ID: uuid.New(), + NotificationID: uuid.New(), + ConfigurationID: uuid.New(), + Provider: "SMTP", + Status: string(notification.SubmissionStatusSuccess), + AttemptCount: 2, + LastError: &lastError, + LastAttemptAt: &lastAttemptAt, + SucceededAt: &succeededAt, + } + + domain := submissionToDomain(model) + + assert.Equal(t, model.ID, domain.ID) + assert.Equal(t, model.NotificationID, domain.NotificationID) + assert.Equal(t, model.ConfigurationID, domain.ConfigurationID) + assert.Equal(t, model.Provider, domain.Provider) + assert.Equal(t, notification.SubmissionStatusSuccess, domain.Status) + assert.Equal(t, model.AttemptCount, domain.AttemptCount) + assert.Equal(t, model.LastError, domain.LastError) + assert.Equal(t, model.LastAttemptAt, domain.LastAttemptAt) + assert.Equal(t, model.SucceededAt, domain.SucceededAt) + }) + + t.Run("maps all submission statuses", func(t *testing.T) { + statuses := []notification.SubmissionStatus{ + notification.SubmissionStatusPending, + notification.SubmissionStatusSuccess, + notification.SubmissionStatusFailed, + notification.SubmissionStatusSkipped, + } + + for _, status := range statuses { + model := &providerSubmissionModel{ + ID: uuid.New(), + Status: string(status), + } + + domain := submissionToDomain(model) + + assert.Equal(t, status, domain.Status) + } + }) + }) + + t.Run("submissionToModel", func(t *testing.T) { + t.Run("successfully converts a complete domain to model", func(t *testing.T) { + lastAttemptAt := time.Now().UTC().Truncate(time.Second) + domain := ¬ification.ProviderSubmission{ + ID: uuid.New(), + NotificationID: uuid.New(), + ConfigurationID: uuid.New(), + Provider: "SMTP", + Status: notification.SubmissionStatusFailed, + AttemptCount: 3, + LastError: new("timeout"), + LastAttemptAt: &lastAttemptAt, + } + + model := submissionToModel(domain) + + assert.Equal(t, domain.ID, model.ID) + assert.Equal(t, domain.NotificationID, model.NotificationID) + assert.Equal(t, domain.ConfigurationID, model.ConfigurationID) + assert.Equal(t, domain.Provider, model.Provider) + assert.Equal(t, string(domain.Status), model.Status) + assert.Equal(t, domain.AttemptCount, model.AttemptCount) + assert.Equal(t, domain.LastError, model.LastError) + assert.Equal(t, domain.LastAttemptAt, model.LastAttemptAt) + assert.Nil(t, model.SucceededAt) + }) + + t.Run("round trips submission status through model", func(t *testing.T) { + domain := ¬ification.ProviderSubmission{ + ID: uuid.New(), + Status: notification.SubmissionStatusSkipped, + } + + roundTrip := submissionToDomain(submissionToModel(domain)) + + assert.Equal(t, domain.Status, roundTrip.Status) + }) + }) + + t.Run("relatedEntityToDomain", func(t *testing.T) { + t.Run("successfully converts a complete model to domain", func(t *testing.T) { + notificationID := uuid.New() + entityID := uuid.New() + name := "example.com" + + model := &relatedEntityModel{ + NotificationID: notificationID, + EntityType: "certificate", + EntityID: entityID, + Name: &name, + } + + domain := relatedEntityToDomain(model) + + assert.Equal(t, notificationID, domain.NotificationID) + assert.Equal(t, "certificate", domain.Type) + assert.Equal(t, entityID, domain.ID) + assert.Equal(t, name, domain.Name) + }) + + t.Run("maps nil name to empty string", func(t *testing.T) { + model := &relatedEntityModel{ + NotificationID: uuid.New(), + EntityType: "host", + EntityID: uuid.New(), + } + + domain := relatedEntityToDomain(model) + + assert.Empty(t, domain.Name) + }) + }) + + t.Run("relatedEntityToModel", func(t *testing.T) { + t.Run("successfully converts a complete domain to model", func(t *testing.T) { + notificationID := uuid.New() + entityID := uuid.New() + + domain := notification.StoredRelatedEntity{ + NotificationID: notificationID, + Type: "certificate", + ID: entityID, + Name: "example.com", + } + + model := relatedEntityToModel(domain) + + assert.Equal(t, notificationID, model.NotificationID) + assert.Equal(t, domain.Type, model.EntityType) + assert.Equal(t, entityID, model.EntityID) + require.NotNil(t, model.Name) + assert.Equal(t, domain.Name, *model.Name) + }) + + t.Run("maps empty name to nil", func(t *testing.T) { + domain := notification.StoredRelatedEntity{ + NotificationID: uuid.New(), + Type: "host", + ID: uuid.New(), + } + + model := relatedEntityToModel(domain) + + assert.Nil(t, model.Name) + }) + + t.Run("round trips related entity through model", func(t *testing.T) { + domain := notification.StoredRelatedEntity{ + NotificationID: uuid.New(), + Type: "certificate", + ID: uuid.New(), + Name: "example.com", + } + + roundTrip := relatedEntityToDomain(new(relatedEntityToModel(domain))) + + assert.Equal(t, domain, roundTrip) + }) + }) +} diff --git a/database/notification/model.go b/database/notification/model.go new file mode 100644 index 000000000..ed168ebf2 --- /dev/null +++ b/database/notification/model.go @@ -0,0 +1,53 @@ +package notification + +import ( + "time" + + "github.com/google/uuid" + "github.com/uptrace/bun" +) + +type notificationModel struct { + bun.BaseModel `bun:"notification"` + CreatedAt time.Time `bun:"created_at,notnull"` + ReadAt *time.Time `bun:"read_at"` + Payload string `bun:"payload,notnull"` + Title string `bun:"title,notnull"` + Summary string `bun:"summary,notnull"` + Category string `bun:"category,notnull"` + ID uuid.UUID `bun:"id,pk"` + UserID uuid.UUID `bun:"user_id,notnull"` + DeliveryCompleted bool `bun:"delivery_completed,notnull"` +} + +type configurationModel struct { + bun.BaseModel `bun:"notification_configuration"` + Categories *string `bun:"categories"` + Parameters string `bun:"parameters,notnull"` + Name string `bun:"name,notnull"` + Provider string `bun:"provider,notnull"` + ID uuid.UUID `bun:"id,pk"` + UserID uuid.UUID `bun:"user_id,notnull"` + Enabled bool `bun:"enabled,notnull"` +} + +type providerSubmissionModel struct { + bun.BaseModel `bun:"notification_provider_submission"` + LastError *string `bun:"last_error"` + LastAttemptAt *time.Time `bun:"last_attempt_at"` + SucceededAt *time.Time `bun:"succeeded_at"` + Status string `bun:"status,notnull"` + Provider string `bun:"provider,notnull"` + AttemptCount int `bun:"attempt_count,notnull"` + ID uuid.UUID `bun:"id,pk"` + NotificationID uuid.UUID `bun:"notification_id,notnull"` + ConfigurationID uuid.UUID `bun:"configuration_id,notnull"` +} + +type relatedEntityModel struct { + bun.BaseModel `bun:"notification_related_entity"` + Name *string `bun:"name"` + EntityType string `bun:"entity_type,notnull"` + NotificationID uuid.UUID `bun:"notification_id,pk"` + EntityID uuid.UUID `bun:"entity_id,pk"` +} diff --git a/database/notification/repository.go b/database/notification/repository.go new file mode 100644 index 000000000..8203e7019 --- /dev/null +++ b/database/notification/repository.go @@ -0,0 +1,561 @@ +package notification + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/google/uuid" + "github.com/uptrace/bun" + + "dillmann.com.br/nginx-ignition/core/common/pagination" + "dillmann.com.br/nginx-ignition/core/notification" + "dillmann.com.br/nginx-ignition/database/common/constants" + "dillmann.com.br/nginx-ignition/database/common/database" +) + +const ( + byUserIDFilter = "user_id = ?" + unreadFilter = "read_at IS NULL" + byNotificationIDFilter = "notification_id = ?" +) + +type repository struct { + database *database.Database +} + +func New(db *database.Database) notification.Repository { + return &repository{ + database: db, + } +} + +func (r *repository) SaveNotification( + ctx context.Context, + value *notification.Notification, + relatedEntities []notification.StoredRelatedEntity, +) error { + transaction, err := r.database.Begin() + if err != nil { + return err + } + + //nolint:errcheck + defer transaction.Rollback() + + model, err := notificationToModel(value) + if err != nil { + return err + } + + _, err = transaction.NewInsert().Model(model).Exec(ctx) + if err != nil { + return err + } + + if len(relatedEntities) > 0 { + entityModels := make([]relatedEntityModel, 0, len(relatedEntities)) + for _, entity := range relatedEntities { + entityModels = append(entityModels, relatedEntityToModel(entity)) + } + + _, err = transaction.NewInsert().Model(&entityModels).Exec(ctx) + if err != nil { + return err + } + } + + return transaction.Commit() +} + +func (r *repository) FindNotificationByIDAndUserID( + ctx context.Context, + notificationID, userID uuid.UUID, +) (*notification.Notification, error) { + var model notificationModel + + err := r.database.Select(). + Model(&model). + Where(constants.ByIDFilter, notificationID). + Where(byUserIDFilter, userID). + Scan(ctx) + + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return notificationToDomain(&model) +} + +func (r *repository) FindNotificationPage( + ctx context.Context, + userID uuid.UUID, + pageSize, pageNumber int, + searchTerms *string, +) (*pagination.Page[notification.Notification], error) { + models := make([]notificationModel, 0) + + applyFilters := func(query *bun.SelectQuery) *bun.SelectQuery { + query = query.Where(byUserIDFilter, userID) + + if searchTerms != nil { + query = query.Where( + "LOWER(title) LIKE LOWER(?) OR LOWER(summary) LIKE LOWER(?)", + "%"+*searchTerms+"%", + "%"+*searchTerms+"%", + ) + } + + return query + } + + count, err := applyFilters( + r.database.Select().Model((*notificationModel)(nil)), + ).Count(ctx) + if err != nil { + return nil, err + } + + err = applyFilters( + r.database.Select().Model(&models), + ). + Limit(pageSize). + Offset(pageSize * pageNumber). + OrderExpr("?TableAlias.created_at DESC"). + Scan(ctx) + if err != nil { + return nil, err + } + + contents := make([]notification.Notification, 0, len(models)) + for index := range models { + item, convertErr := notificationToDomain(&models[index]) + if convertErr != nil { + return nil, convertErr + } + contents = append(contents, *item) + } + + return pagination.New(pageNumber, pageSize, count, contents), nil +} + +func (r *repository) MarkNotificationAsRead( + ctx context.Context, + userID, notificationID uuid.UUID, +) error { + now := time.Now() + + _, err := r.database.Update(). + Model((*notificationModel)(nil)). + Set("read_at = ?", now). + Where(constants.ByIDFilter, notificationID). + Where(byUserIDFilter, userID). + Where(unreadFilter). + Exec(ctx) + + return err +} + +func (r *repository) MarkAllNotificationsAsRead(ctx context.Context, userID uuid.UUID) error { + now := time.Now() + + _, err := r.database.Update(). + Model((*notificationModel)(nil)). + Set("read_at = ?", now). + Where(byUserIDFilter, userID). + Where(unreadFilter). + Exec(ctx) + + return err +} + +func (r *repository) CountUnreadNotifications(ctx context.Context, userID uuid.UUID) (int, error) { + return r.database.Select(). + Model((*notificationModel)(nil)). + Where(byUserIDFilter, userID). + Where(unreadFilter). + Count(ctx) +} + +func (r *repository) GetLastForUserCategoryAndRelatedEntity( + ctx context.Context, + userID uuid.UUID, + category notification.Category, + entityType string, + entityID uuid.UUID, +) (*notification.Notification, error) { + var model notificationModel + + err := r.database.Select(). + Model(&model). + Join("INNER JOIN notification_related_entity AS related_entity ON related_entity.notification_id = ?TableAlias.id"). + Where(byUserIDFilter, userID). + Where("category = ?", string(category)). + Where("related_entity.entity_type = ?", entityType). + Where("related_entity.entity_id = ?", entityID). + OrderExpr("?TableAlias.created_at DESC"). + Limit(1). + Scan(ctx) + + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return notificationToDomain(&model) +} + +func (r *repository) SetDeliveryCompleted( + ctx context.Context, + notificationID uuid.UUID, + completed bool, +) error { + _, err := r.database.Update(). + Model((*notificationModel)(nil)). + Set("delivery_completed = ?", completed). + Where(constants.ByIDFilter, notificationID). + Exec(ctx) + + return err +} + +func (r *repository) FindRelatedEntitiesByNotificationID( + ctx context.Context, + notificationID uuid.UUID, +) ([]notification.StoredRelatedEntity, error) { + models := make([]relatedEntityModel, 0) + + err := r.database.Select(). + Model(&models). + Where(byNotificationIDFilter, notificationID). + Scan(ctx) + if err != nil { + return nil, err + } + + result := make([]notification.StoredRelatedEntity, 0, len(models)) + for index := range models { + result = append(result, relatedEntityToDomain(&models[index])) + } + + return result, nil +} + +func (r *repository) FindRelatedEntitiesByNotificationIDs( + ctx context.Context, + notificationIDs []uuid.UUID, +) (map[uuid.UUID][]notification.StoredRelatedEntity, error) { + result := make(map[uuid.UUID][]notification.StoredRelatedEntity) + if len(notificationIDs) == 0 { + return result, nil + } + + models := make([]relatedEntityModel, 0) + + err := r.database.Select(). + Model(&models). + Where("notification_id IN (?)", bun.List(notificationIDs)). + Scan(ctx) + if err != nil { + return nil, err + } + + for index := range models { + entity := relatedEntityToDomain(&models[index]) + result[entity.NotificationID] = append(result[entity.NotificationID], entity) + } + + return result, nil +} + +func (r *repository) FindConfigurationByIDAndUserID( + ctx context.Context, + configurationID, userID uuid.UUID, +) (*notification.Configuration, error) { + var model configurationModel + + err := r.database.Select(). + Model(&model). + Where(constants.ByIDFilter, configurationID). + Where(byUserIDFilter, userID). + Scan(ctx) + + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return configurationToDomain(&model) +} + +func (r *repository) FindConfigurationsByUserID( + ctx context.Context, + userID uuid.UUID, +) ([]notification.Configuration, error) { + models := make([]configurationModel, 0) + + err := r.database.Select(). + Model(&models). + Where(byUserIDFilter, userID). + Order("name"). + Scan(ctx) + if err != nil { + return nil, err + } + + return configurationsToDomain(models) +} + +func (r *repository) SaveConfiguration( + ctx context.Context, + value *notification.Configuration, +) error { + transaction, err := r.database.Begin() + if err != nil { + return err + } + + //nolint:errcheck + defer transaction.Rollback() + + model, err := configurationToModel(value) + if err != nil { + return err + } + + exists, err := transaction.NewSelect(). + Model((*configurationModel)(nil)). + Where(constants.ByIDFilter, model.ID). + Exists(ctx) + if err != nil { + return err + } + + if exists { + result, updateErr := transaction.NewUpdate(). + Model(model). + Where(constants.ByIDFilter, model.ID). + Where(byUserIDFilter, value.UserID). + Exec(ctx) + if updateErr != nil { + return updateErr + } + + affected, rowsErr := result.RowsAffected() + if rowsErr != nil { + return rowsErr + } + if affected == 0 { + return notification.ErrConfigurationNotFound + } + } else { + _, err = transaction.NewInsert().Model(model).Exec(ctx) + if err != nil { + return err + } + } + + return transaction.Commit() +} + +func (r *repository) DeleteConfigurationByIDAndUserID( + ctx context.Context, + configurationID, userID uuid.UUID, +) error { + _, err := r.database.Delete(). + Model((*configurationModel)(nil)). + Where(constants.ByIDFilter, configurationID). + Where(byUserIDFilter, userID). + Exec(ctx) + + return err +} + +func (r *repository) ConfigurationExistsByName( + ctx context.Context, + userID uuid.UUID, + name string, + excludeID *uuid.UUID, +) (bool, error) { + query := r.database.Select(). + Model((*configurationModel)(nil)). + Where(byUserIDFilter, userID). + Where("name = ?", name) + + if excludeID != nil { + query = query.Where("id <> ?", *excludeID) + } + + return query.Exists(ctx) +} + +func (r *repository) FindEnabledConfigurationsByUserID( + ctx context.Context, + userID uuid.UUID, +) ([]notification.Configuration, error) { + models := make([]configurationModel, 0) + + err := r.database.Select(). + Model(&models). + Where(byUserIDFilter, userID). + Where("enabled = ?", true). + Order("name"). + Scan(ctx) + if err != nil { + return nil, err + } + + return configurationsToDomain(models) +} + +func (r *repository) SaveProviderSubmissions( + ctx context.Context, + submissions []notification.ProviderSubmission, +) error { + if len(submissions) == 0 { + return nil + } + + models := make([]providerSubmissionModel, 0, len(submissions)) + for index := range submissions { + models = append(models, *submissionToModel(&submissions[index])) + } + + _, err := r.database.Insert().Model(&models).Exec(ctx) + return err +} + +func (r *repository) FindSubmissionsByNotificationID( + ctx context.Context, + notificationID uuid.UUID, +) ([]notification.ProviderSubmission, error) { + models := make([]providerSubmissionModel, 0) + + err := r.database.Select(). + Model(&models). + Where(byNotificationIDFilter, notificationID). + Scan(ctx) + if err != nil { + return nil, err + } + + result := make([]notification.ProviderSubmission, 0, len(models)) + for index := range models { + result = append(result, *submissionToDomain(&models[index])) + } + + return result, nil +} + +func (r *repository) FindSubmissionsByNotificationIDs( + ctx context.Context, + notificationIDs []uuid.UUID, +) (map[uuid.UUID][]notification.ProviderSubmission, error) { + result := make(map[uuid.UUID][]notification.ProviderSubmission) + if len(notificationIDs) == 0 { + return result, nil + } + + models := make([]providerSubmissionModel, 0) + + err := r.database.Select(). + Model(&models). + Where("notification_id IN (?)", bun.List(notificationIDs)). + Scan(ctx) + if err != nil { + return nil, err + } + + for index := range models { + submission := submissionToDomain(&models[index]) + result[submission.NotificationID] = append(result[submission.NotificationID], *submission) + } + + return result, nil +} + +func (r *repository) UpdateProviderSubmission( + ctx context.Context, + value *notification.ProviderSubmission, +) error { + model := submissionToModel(value) + + _, err := r.database.Update(). + Model(model). + WherePK(). + Exec(ctx) + + return err +} + +func (r *repository) FindPendingSubmissionsByNotificationID( + ctx context.Context, + notificationID uuid.UUID, +) ([]notification.ProviderSubmission, error) { + models := make([]providerSubmissionModel, 0) + + err := r.database.Select(). + Model(&models). + Where(byNotificationIDFilter, notificationID). + Where("status = ?", string(notification.SubmissionStatusPending)). + Scan(ctx) + if err != nil { + return nil, err + } + + result := make([]notification.ProviderSubmission, 0, len(models)) + for index := range models { + result = append(result, *submissionToDomain(&models[index])) + } + + return result, nil +} + +func (r *repository) FindNotificationsWithIncompleteDelivery( + ctx context.Context, +) ([]notification.Notification, error) { + models := make([]notificationModel, 0) + + err := r.database.Select(). + Model(&models). + Where("delivery_completed = ?", false). + Order("created_at"). + Scan(ctx) + if err != nil { + return nil, err + } + + result := make([]notification.Notification, 0, len(models)) + for index := range models { + item, convertErr := notificationToDomain(&models[index]) + if convertErr != nil { + return nil, convertErr + } + result = append(result, *item) + } + + return result, nil +} + +func configurationsToDomain(models []configurationModel) ([]notification.Configuration, error) { + result := make([]notification.Configuration, 0, len(models)) + for index := range models { + item, err := configurationToDomain(&models[index]) + if err != nil { + return nil, err + } + result = append(result, *item) + } + return result, nil +} diff --git a/database/notification/repository_test.go b/database/notification/repository_test.go new file mode 100644 index 000000000..a4cfa5ea8 --- /dev/null +++ b/database/notification/repository_test.go @@ -0,0 +1,524 @@ +package notification + +import ( + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "dillmann.com.br/nginx-ignition/core/notification" + coreuser "dillmann.com.br/nginx-ignition/core/user" + "dillmann.com.br/nginx-ignition/database/common/database" + "dillmann.com.br/nginx-ignition/database/common/testutils" + dbuser "dillmann.com.br/nginx-ignition/database/user" +) + +func Test_Repository(t *testing.T) { + testutils.RunWithMockedDatabases(t, runRepositoryTests) +} + +func runRepositoryTests(t *testing.T, db *database.Database) { + repo := New(db) + userRepo := dbuser.New(db) + + user := &coreuser.User{ + ID: uuid.New(), + Name: "Test User", + Username: "testuser-" + uuid.New().String(), + NotificationLanguage: "en", + PasswordHash: "hash", + PasswordSalt: "salt", + Permissions: coreuser.Permissions{ + Hosts: coreuser.ReadWriteAccessLevel, + Streams: coreuser.ReadWriteAccessLevel, + Certificates: coreuser.ReadWriteAccessLevel, + Logs: coreuser.ReadOnlyAccessLevel, + Integrations: coreuser.ReadWriteAccessLevel, + AccessLists: coreuser.ReadWriteAccessLevel, + Settings: coreuser.ReadWriteAccessLevel, + Users: coreuser.ReadWriteAccessLevel, + NginxServer: coreuser.ReadWriteAccessLevel, + ExportData: coreuser.ReadOnlyAccessLevel, + VPNs: coreuser.ReadWriteAccessLevel, + Caches: coreuser.ReadWriteAccessLevel, + TrafficStats: coreuser.ReadOnlyAccessLevel, + }, + Enabled: true, + } + require.NoError(t, userRepo.Save(t.Context(), user)) + + t.Run("SaveNotification", func(t *testing.T) { + t.Run("persists notification with related entities", func(t *testing.T) { + certificateID := uuid.New() + value := newNotification(user.ID) + relatedEntities := []notification.StoredRelatedEntity{ + { + NotificationID: value.ID, + Type: "CERTIFICATE", + ID: certificateID, + Name: "example.com", + }, + } + + require.NoError(t, repo.SaveNotification(t.Context(), value, relatedEntities)) + + saved, err := repo.FindNotificationByIDAndUserID(t.Context(), value.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, saved) + assert.Equal(t, value.Title, saved.Title) + assert.Equal(t, value.Summary, saved.Summary) + assert.Equal(t, value.Category, saved.Category) + assert.Equal(t, value.Payload.Tags, saved.Payload.Tags) + + entities, err := repo.FindRelatedEntitiesByNotificationID(t.Context(), value.ID) + require.NoError(t, err) + require.Len(t, entities, 1) + assert.Equal(t, "CERTIFICATE", entities[0].Type) + assert.Equal(t, certificateID, entities[0].ID) + }) + }) + + t.Run("FindNotificationPage", func(t *testing.T) { + t.Run("filters notifications by search terms", func(t *testing.T) { + matching := newNotification(user.ID) + matching.ID = uuid.New() + matching.Title = "Certificate renewed" + matching.Summary = "example.com was renewed" + require.NoError(t, repo.SaveNotification(t.Context(), matching, nil)) + + other := newNotification(user.ID) + other.ID = uuid.New() + other.Title = "Nginx reload failed" + other.Summary = "reload failed" + require.NoError(t, repo.SaveNotification(t.Context(), other, nil)) + + searchTerm := new("certificate") + page, err := repo.FindNotificationPage( + t.Context(), + user.ID, + 10, + 0, + searchTerm, + ) + require.NoError(t, err) + assert.GreaterOrEqual(t, page.TotalItems, 1) + + for _, item := range page.Contents { + assert.Contains( + t, + strings.ToLower(item.Title)+strings.ToLower(item.Summary), + *searchTerm, + ) + } + }) + }) + + t.Run("MarkNotificationAsRead", func(t *testing.T) { + t.Run("sets read_at for owned notification", func(t *testing.T) { + value := newNotification(user.ID) + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + require.NoError(t, repo.MarkNotificationAsRead(t.Context(), user.ID, value.ID)) + + saved, err := repo.FindNotificationByIDAndUserID(t.Context(), value.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, saved.ReadAt) + }) + }) + + t.Run("MarkAllNotificationsAsRead", func(t *testing.T) { + t.Run("marks every unread notification for user", func(t *testing.T) { + value := newNotification(user.ID) + value.ID = uuid.New() + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + require.NoError(t, repo.MarkAllNotificationsAsRead(t.Context(), user.ID)) + + count, err := repo.CountUnreadNotifications(t.Context(), user.ID) + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + }) + + t.Run("GetLastForUserCategoryAndRelatedEntity", func(t *testing.T) { + t.Run("returns most recent matching notification", func(t *testing.T) { + certificateID := uuid.New() + older := newNotification(user.ID) + older.ID = uuid.New() + older.Category = notification.CategoryCertificateExpiring + older.CreatedAt = time.Now().UTC().Add(-2 * time.Hour) + require.NoError( + t, + repo.SaveNotification(t.Context(), older, []notification.StoredRelatedEntity{ + {NotificationID: older.ID, Type: "CERTIFICATE", ID: certificateID}, + }), + ) + + newer := newNotification(user.ID) + newer.ID = uuid.New() + newer.Category = notification.CategoryCertificateExpiring + newer.CreatedAt = time.Now().UTC() + require.NoError( + t, + repo.SaveNotification(t.Context(), newer, []notification.StoredRelatedEntity{ + {NotificationID: newer.ID, Type: "CERTIFICATE", ID: certificateID}, + }), + ) + + last, err := repo.GetLastForUserCategoryAndRelatedEntity( + t.Context(), + user.ID, + notification.CategoryCertificateExpiring, + "CERTIFICATE", + certificateID, + ) + require.NoError(t, err) + require.NotNil(t, last) + assert.Equal(t, newer.ID, last.ID) + }) + }) + + t.Run("SaveConfiguration", func(t *testing.T) { + t.Run("round-trips categories encoding", func(t *testing.T) { + value := newConfiguration(user.ID) + value.Categories = new([]notification.Category{ + notification.CategoryCertificateRenewed, + }) + require.NoError(t, repo.SaveConfiguration(t.Context(), value)) + + saved, err := repo.FindConfigurationByIDAndUserID(t.Context(), value.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, saved) + require.NotNil(t, saved.Categories) + assert.Equal(t, *value.Categories, *saved.Categories) + }) + + t.Run("null categories means all categories", func(t *testing.T) { + value := newConfiguration(user.ID) + value.ID = uuid.New() + value.Categories = nil + require.NoError(t, repo.SaveConfiguration(t.Context(), value)) + + saved, err := repo.FindConfigurationByIDAndUserID(t.Context(), value.ID, user.ID) + require.NoError(t, err) + assert.Nil(t, saved.Categories) + }) + + t.Run("rejects update for another user configuration", func(t *testing.T) { + otherUser := &coreuser.User{ + ID: uuid.New(), + Name: "Other User", + Username: "otheruser-" + uuid.New().String(), + NotificationLanguage: "en", + PasswordHash: "hash", + PasswordSalt: "salt", + Permissions: user.Permissions, + Enabled: true, + } + require.NoError(t, userRepo.Save(t.Context(), otherUser)) + + value := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), value)) + + attempt := newConfiguration(otherUser.ID) + attempt.ID = value.ID + attempt.Name = "hijacked" + + err := repo.SaveConfiguration(t.Context(), attempt) + require.ErrorIs(t, err, notification.ErrConfigurationNotFound) + + saved, findErr := repo.FindConfigurationByIDAndUserID(t.Context(), value.ID, user.ID) + require.NoError(t, findErr) + require.NotNil(t, saved) + assert.Equal(t, value.Name, saved.Name) + }) + }) + + t.Run("SaveProviderSubmissions", func(t *testing.T) { + t.Run("stores pending submissions for delivery", func(t *testing.T) { + value := newNotification(user.ID) + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + configuration := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), configuration)) + + submission := notification.ProviderSubmission{ + ID: uuid.New(), + NotificationID: value.ID, + ConfigurationID: configuration.ID, + Provider: configuration.Provider, + Status: notification.SubmissionStatusPending, + AttemptCount: 0, + } + require.NoError( + t, + repo.SaveProviderSubmissions( + t.Context(), + []notification.ProviderSubmission{submission}, + ), + ) + + pending, err := repo.FindPendingSubmissionsByNotificationID(t.Context(), value.ID) + require.NoError(t, err) + require.Len(t, pending, 1) + assert.Equal(t, notification.SubmissionStatusPending, pending[0].Status) + }) + }) + + t.Run("FindNotificationsWithIncompleteDelivery", func(t *testing.T) { + t.Run("returns notifications awaiting delivery completion", func(t *testing.T) { + value := newNotification(user.ID) + value.ID = uuid.New() + value.DeliveryCompleted = false + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + items, err := repo.FindNotificationsWithIncompleteDelivery(t.Context()) + require.NoError(t, err) + + found := false + for _, item := range items { + if item.ID == value.ID { + found = true + break + } + } + assert.True(t, found) + }) + }) + + t.Run("DeleteConfigurationByIDAndUserID", func(t *testing.T) { + t.Run("deletes configuration with related submissions", func(t *testing.T) { + value := newNotification(user.ID) + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + configuration := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), configuration)) + + submission := notification.ProviderSubmission{ + ID: uuid.New(), + NotificationID: value.ID, + ConfigurationID: configuration.ID, + Provider: configuration.Provider, + Status: notification.SubmissionStatusPending, + AttemptCount: 0, + } + require.NoError( + t, + repo.SaveProviderSubmissions( + t.Context(), + []notification.ProviderSubmission{submission}, + ), + ) + + require.NoError( + t, + repo.DeleteConfigurationByIDAndUserID(t.Context(), configuration.ID, user.ID), + ) + + saved, err := repo.FindConfigurationByIDAndUserID( + t.Context(), + configuration.ID, + user.ID, + ) + require.NoError(t, err) + assert.Nil(t, saved) + }) + }) + + t.Run("ConfigurationExistsByName", func(t *testing.T) { + t.Run("returns true when another configuration uses the name", func(t *testing.T) { + value := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), value)) + + otherID := uuid.New() + exists, err := repo.ConfigurationExistsByName( + t.Context(), + user.ID, + value.Name, + &otherID, + ) + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("returns false when excluding the same configuration", func(t *testing.T) { + value := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), value)) + + exists, err := repo.ConfigurationExistsByName( + t.Context(), + user.ID, + value.Name, + &value.ID, + ) + require.NoError(t, err) + assert.False(t, exists) + }) + }) + + t.Run("SetDeliveryCompleted", func(t *testing.T) { + t.Run("updates delivery flag", func(t *testing.T) { + value := newNotification(user.ID) + value.ID = uuid.New() + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + require.NoError(t, repo.SetDeliveryCompleted(t.Context(), value.ID, true)) + + items, err := repo.FindNotificationsWithIncompleteDelivery(t.Context()) + require.NoError(t, err) + + for _, item := range items { + assert.NotEqual(t, value.ID, item.ID) + } + }) + }) + + t.Run("CountUnreadNotifications", func(t *testing.T) { + t.Run("counts only unread notifications for user", func(t *testing.T) { + unread := newNotification(user.ID) + unread.ID = uuid.New() + require.NoError(t, repo.SaveNotification(t.Context(), unread, nil)) + + read := newNotification(user.ID) + read.ID = uuid.New() + require.NoError(t, repo.SaveNotification(t.Context(), read, nil)) + require.NoError(t, repo.MarkNotificationAsRead(t.Context(), user.ID, read.ID)) + + count, err := repo.CountUnreadNotifications(t.Context(), user.ID) + require.NoError(t, err) + assert.GreaterOrEqual(t, count, 1) + }) + }) + + t.Run("FindEnabledConfigurationsByUserID", func(t *testing.T) { + t.Run("returns only enabled configurations", func(t *testing.T) { + enabled := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), enabled)) + + disabled := newConfiguration(user.ID) + disabled.ID = uuid.New() + disabled.Enabled = false + require.NoError(t, repo.SaveConfiguration(t.Context(), disabled)) + + configurations, err := repo.FindEnabledConfigurationsByUserID(t.Context(), user.ID) + require.NoError(t, err) + + for _, configuration := range configurations { + assert.True(t, configuration.Enabled) + } + }) + }) + + t.Run("UpdateProviderSubmission", func(t *testing.T) { + t.Run("persists submission status changes", func(t *testing.T) { + value := newNotification(user.ID) + require.NoError(t, repo.SaveNotification(t.Context(), value, nil)) + + configuration := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), configuration)) + + submission := notification.ProviderSubmission{ + ID: uuid.New(), + NotificationID: value.ID, + ConfigurationID: configuration.ID, + Provider: configuration.Provider, + Status: notification.SubmissionStatusPending, + } + require.NoError( + t, + repo.SaveProviderSubmissions( + t.Context(), + []notification.ProviderSubmission{submission}, + ), + ) + + submission.Status = notification.SubmissionStatusSuccess + require.NoError(t, repo.UpdateProviderSubmission(t.Context(), &submission)) + + submissions, err := repo.FindSubmissionsByNotificationID(t.Context(), value.ID) + require.NoError(t, err) + require.Len(t, submissions, 1) + assert.Equal(t, notification.SubmissionStatusSuccess, submissions[0].Status) + }) + }) + + t.Run("FindSubmissionsByNotificationIDs", func(t *testing.T) { + t.Run("returns submissions grouped by notification", func(t *testing.T) { + first := newNotification(user.ID) + first.ID = uuid.New() + second := newNotification(user.ID) + second.ID = uuid.New() + require.NoError(t, repo.SaveNotification(t.Context(), first, nil)) + require.NoError(t, repo.SaveNotification(t.Context(), second, nil)) + + configuration := newConfiguration(user.ID) + require.NoError(t, repo.SaveConfiguration(t.Context(), configuration)) + + firstSubmission := notification.ProviderSubmission{ + ID: uuid.New(), + NotificationID: first.ID, + ConfigurationID: configuration.ID, + Provider: configuration.Provider, + Status: notification.SubmissionStatusPending, + } + secondSubmission := notification.ProviderSubmission{ + ID: uuid.New(), + NotificationID: second.ID, + ConfigurationID: configuration.ID, + Provider: configuration.Provider, + Status: notification.SubmissionStatusPending, + } + require.NoError( + t, + repo.SaveProviderSubmissions( + t.Context(), + []notification.ProviderSubmission{ + firstSubmission, + secondSubmission, + }, + ), + ) + + grouped, err := repo.FindSubmissionsByNotificationIDs( + t.Context(), + []uuid.UUID{first.ID, second.ID}, + ) + require.NoError(t, err) + assert.Len(t, grouped[first.ID], 1) + assert.Len(t, grouped[second.ID], 1) + }) + }) + + t.Run("FindRelatedEntitiesByNotificationIDs", func(t *testing.T) { + t.Run("returns related entities grouped by notification", func(t *testing.T) { + certificateID := uuid.New() + value := newNotification(user.ID) + require.NoError( + t, + repo.SaveNotification( + t.Context(), + value, + []notification.StoredRelatedEntity{ + { + NotificationID: value.ID, + Type: "CERTIFICATE", + ID: certificateID, + Name: "example.com", + }, + }, + ), + ) + + grouped, err := repo.FindRelatedEntitiesByNotificationIDs( + t.Context(), + []uuid.UUID{value.ID}, + ) + require.NoError(t, err) + require.Len(t, grouped[value.ID], 1) + assert.Equal(t, certificateID, grouped[value.ID][0].ID) + }) + }) +} diff --git a/database/user/artifacts_test.go b/database/user/artifacts_test.go index 255125d9f..0802cb3bf 100644 --- a/database/user/artifacts_test.go +++ b/database/user/artifacts_test.go @@ -8,11 +8,12 @@ import ( func newUser() *user.User { return &user.User{ - ID: uuid.New(), - Name: "Test User", - Username: "testuser-" + uuid.New().String(), - PasswordHash: "hash", - PasswordSalt: "salt", + ID: uuid.New(), + Name: "Test User", + Username: "testuser-" + uuid.New().String(), + NotificationLanguage: "en", + PasswordHash: "hash", + PasswordSalt: "salt", Permissions: user.Permissions{ Hosts: user.ReadWriteAccessLevel, Streams: user.ReadWriteAccessLevel, diff --git a/database/user/converter.go b/database/user/converter.go index d1e4e1357..490c9a46f 100644 --- a/database/user/converter.go +++ b/database/user/converter.go @@ -8,12 +8,13 @@ import ( func toDomain(model *userModel) user.User { return user.User{ - ID: model.ID, - Enabled: model.Enabled, - Name: model.Name, - Username: model.Username, - PasswordHash: model.PasswordHash, - PasswordSalt: model.PasswordSalt, + ID: model.ID, + Enabled: model.Enabled, + Name: model.Name, + Username: model.Username, + NotificationLanguage: model.NotificationLanguage, + PasswordHash: model.PasswordHash, + PasswordSalt: model.PasswordSalt, Permissions: user.Permissions{ Hosts: user.AccessLevel(model.HostsAccessLevel), Streams: user.AccessLevel(model.StreamsAccessLevel), @@ -48,6 +49,7 @@ func toModel(domain *user.User) userModel { Enabled: domain.Enabled, Name: domain.Name, Username: domain.Username, + NotificationLanguage: domain.NotificationLanguage, PasswordHash: domain.PasswordHash, PasswordSalt: domain.PasswordSalt, HostsAccessLevel: string(domain.Permissions.Hosts), diff --git a/database/user/converter_test.go b/database/user/converter_test.go index f538a3d92..ce16a3b29 100644 --- a/database/user/converter_test.go +++ b/database/user/converter_test.go @@ -17,6 +17,7 @@ func Test_Converter(t *testing.T) { Enabled: true, Name: "Name", Username: "username", + NotificationLanguage: "en", PasswordHash: "hash", PasswordSalt: "salt", HostsAccessLevel: "READ_WRITE", @@ -42,6 +43,7 @@ func Test_Converter(t *testing.T) { assert.Equal(t, model.Enabled, domain.Enabled) assert.Equal(t, model.Name, domain.Name) assert.Equal(t, model.Username, domain.Username) + assert.Equal(t, model.NotificationLanguage, domain.NotificationLanguage) assert.Equal(t, model.PasswordHash, domain.PasswordHash) assert.Equal(t, model.PasswordSalt, domain.PasswordSalt) assert.Equal(t, user.AccessLevel(model.HostsAccessLevel), domain.Permissions.Hosts) @@ -108,12 +110,13 @@ func Test_Converter(t *testing.T) { t.Run("successfully converts a complete domain to model", func(t *testing.T) { domain := &user.User{ - ID: uuid.New(), - Enabled: true, - Name: "Name", - Username: "username", - PasswordHash: "hash", - PasswordSalt: "salt", + ID: uuid.New(), + Enabled: true, + Name: "Name", + Username: "username", + NotificationLanguage: "pt-BR", + PasswordHash: "hash", + PasswordSalt: "salt", Permissions: user.Permissions{ Hosts: user.ReadWriteAccessLevel, Streams: user.ReadWriteAccessLevel, @@ -141,6 +144,7 @@ func Test_Converter(t *testing.T) { assert.Equal(t, domain.Enabled, model.Enabled) assert.Equal(t, domain.Name, model.Name) assert.Equal(t, domain.Username, model.Username) + assert.Equal(t, domain.NotificationLanguage, model.NotificationLanguage) assert.Equal(t, domain.PasswordHash, model.PasswordHash) assert.Equal(t, domain.PasswordSalt, model.PasswordSalt) assert.Equal(t, string(domain.Permissions.Hosts), model.HostsAccessLevel) diff --git a/database/user/model.go b/database/user/model.go index 1acfd75ba..f723047d6 100644 --- a/database/user/model.go +++ b/database/user/model.go @@ -20,6 +20,7 @@ type userModel struct { LogsAccessLevel string `bun:"logs_access_level,notnull"` Name string `bun:"name,notnull"` Username string `bun:"username,notnull"` + NotificationLanguage string `bun:"notification_language,notnull"` SettingsAccessLevel string `bun:"settings_access_level,notnull"` UsersAccessLevel string `bun:"users_access_level,notnull"` NginxServerAccessLevel string `bun:"nginx_server_access_level,notnull"` diff --git a/database/user/repository.go b/database/user/repository.go index b44154d76..01659afe6 100644 --- a/database/user/repository.go +++ b/database/user/repository.go @@ -125,6 +125,18 @@ func (r *repository) FindPage( return pagination.New(pageNumber, pageSize, count, result), nil } +func (r *repository) ListEnabledIDs(ctx context.Context) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0) + + err := r.database.Select(). + Model((*userModel)(nil)). + Column("id"). + Where("enabled = ?", true). + Scan(ctx, &ids) + + return ids, err +} + func (r *repository) IsEnabledByID(ctx context.Context, id uuid.UUID) (bool, error) { var model userModel @@ -172,9 +184,19 @@ func (r *repository) Save(ctx context.Context, u *user.User) error { model := toModel(u) if exists { - _, err = transaction.NewUpdate().Model(&model).Where(constants.ByIDFilter, u.ID).Exec(ctx) + query := transaction.NewUpdate().Model(&model).Where(constants.ByIDFilter, u.ID) + if u.NotificationLanguage == "" { + query = query.ExcludeColumn("notification_language") + } + + _, err = query.Exec(ctx) } else { - _, err = transaction.NewInsert().Model(&model).Exec(ctx) + query := transaction.NewInsert().Model(&model) + if u.NotificationLanguage == "" { + query = query.ExcludeColumn("notification_language") + } + + _, err = query.Exec(ctx) } if err != nil { diff --git a/database/user/repository_test.go b/database/user/repository_test.go index 21e4a948c..b5ecf6323 100644 --- a/database/user/repository_test.go +++ b/database/user/repository_test.go @@ -54,6 +54,36 @@ func runRepositoryTests(t *testing.T, db *database.Database) { assert.Equal(t, "Updated User", saved.Name) assert.False(t, saved.Enabled) }) + + t.Run("uses database default notification language on insert when omitted", + func(t *testing.T) { + cmd := newUser() + cmd.NotificationLanguage = "" + + err := repo.Save(t.Context(), cmd) + require.NoError(t, err) + + saved, err := repo.FindByID(t.Context(), cmd.ID) + require.NoError(t, err) + require.NotNil(t, saved) + assert.Equal(t, "en", saved.NotificationLanguage) + }) + + t.Run("preserves notification language on update when omitted", func(t *testing.T) { + cmd := newUser() + cmd.NotificationLanguage = "pt" + require.NoError(t, repo.Save(t.Context(), cmd)) + + cmd.Name = "Updated User" + cmd.NotificationLanguage = "" + err := repo.Save(t.Context(), cmd) + require.NoError(t, err) + + saved, err := repo.FindByID(t.Context(), cmd.ID) + require.NoError(t, err) + assert.Equal(t, "Updated User", saved.Name) + assert.Equal(t, "pt", saved.NotificationLanguage) + }) }) t.Run("FindByUsername", func(t *testing.T) { @@ -153,6 +183,25 @@ func runRepositoryTests(t *testing.T, db *database.Database) { }) }) + t.Run("ListEnabledIDs", func(t *testing.T) { + t.Run("returns only enabled user ids", func(t *testing.T) { + enabledUser := newUser() + enabledUser.Enabled = true + require.NoError(t, repo.Save(t.Context(), enabledUser)) + + disabledUser := newUser() + disabledUser.ID = uuid.New() + disabledUser.Username = uuid.New().String() + disabledUser.Enabled = false + require.NoError(t, repo.Save(t.Context(), disabledUser)) + + ids, err := repo.ListEnabledIDs(t.Context()) + require.NoError(t, err) + assert.Contains(t, ids, enabledUser.ID) + assert.NotContains(t, ids, disabledUser.ID) + }) + }) + t.Run("Count", func(t *testing.T) { t.Run("returns total user count", func(t *testing.T) { initial, err := repo.Count(t.Context()) diff --git a/go.work b/go.work index 81fc1f38e..d05eaabd0 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.3 +go 1.26.4 use ( api @@ -13,6 +13,7 @@ use ( i18n integration/docker integration/truenas + notification/smtp tools vpn/netbird vpn/tailscale diff --git a/i18n/AGENTS.md b/i18n/AGENTS.md new file mode 100644 index 000000000..d4de8202d --- /dev/null +++ b/i18n/AGENTS.md @@ -0,0 +1,330 @@ +# i18n Properties File Guide for AI Agents + +This document provides comprehensive instructions for AI models working with the i18n (internationalization) properties files in this project. + +## Overview + +The `messages_en.properties` file contains all user-facing text strings for the nginx-ignition application. These keys are used in both the Go backend and the TypeScript frontend. + +## File Format + +### Basic Structure + +```properties +key/path/suffix=Value text here +``` + +- **Key**: Left side of `=`, uses `/` as separator (path-like format) +- **Value**: Right side of `=`, the actual translated text +- **No quotes** around values +- **One key per line** - this is critical and must be preserved +- **No trailing whitespace** + +### Key Format + +Keys follow a hierarchical path structure that mirrors where they are used in the codebase: + +``` +{module}/{submodule}/.../{descriptor} +``` + +#### Examples: +```properties +core/accesslist/in-use=Access list is in use by one or more hosts +frontend/authentication/login-button=Log in +certificate/letsencrypt/dns/azure/client-id=Azure client ID +vpn/tailscale/auth-key=Tailscale auth key +``` + +## Key Naming Conventions + +### 1. Path Prefix = Folder Location + +The key prefix MUST match the folder path where the key is used in the codebase: + +| Code Location | Key Prefix | +|---------------|------------| +| `core/accesslist/*.go` | `core/accesslist/` | +| `certificate/letsencrypt/dns/azure/*.go` | `certificate/letsencrypt/dns/azure/` | +| `frontend/src/domain/authentication/*.tsx` | `frontend/authentication/` | +| `frontend/src/core/components/shell/*.tsx` | `frontend/components/shell/` | +| `api/common/authorization/*.go` | `api/common/authorization/` | +| `integration/docker/*.go` | `integration/docker/` | +| `vpn/tailscale/*.go` | `vpn/tailscale/` | +| `frontend/src/domain/trafficstats/*.tsx` | `frontend/traffic-stats/` | + +**Frontend Exception**: For frontend paths, omit `src/domain/` or `src/core/`: +- `frontend/src/domain/accesslist/` → `frontend/accesslist/` +- `frontend/src/core/components/shell/` → `frontend/components/shell/` + +### 2. Suffix = Descriptive Name + +The suffix (after the last `/`) should be: +- **Concise**: Use the minimum words needed to describe the purpose +- **Descriptive**: Clearly indicate what the text is for +- **Non-redundant**: Do NOT repeat information already in the path + +#### ✅ GOOD Examples: +```properties +certificate/letsencrypt/dns/azure/client-id=Azure client ID +core/accesslist/in-use=Access list is in use +integration/docker/name=Docker +vpn/tailscale/auth-key=Tailscale auth key +frontend/authentication/login-button=Log in +``` + +#### ❌ BAD Examples (redundant): +```properties +# BAD: "azure" already in path, don't repeat it +certificate/letsencrypt/dns/azure/azure-client-id=Azure client ID + +# BAD: "error" is redundant when context implies it +core/accesslist/error-in-use=Access list is in use + +# BAD: "validation" is redundant +core/binding/validation-invalid-ip=Value is not a valid IP address + +# BAD: "lets-encrypt-dns-azure" all redundant with path +certificate/letsencrypt/dns/azure/lets-encrypt-dns-azure-client-id=Azure client ID +``` + +### 3. Keys Used in Multiple Places + +If a key is used in multiple folders, use the `common/` prefix: + +```properties +common/cannot-be-empty=Value cannot be empty +common/invalid-url=Value is not a valid URL +common/value-missing=A value is required +``` + +Important: If you create a new key and code generation fails with the message that such value already exists, DO NOT +USE THE KEY THAT ALREADY EXISTS. Move the key to the `common/` group following the examples above. + +### 4. Common Suffix Patterns + +| Pattern | Usage | Example | +|---------|-------|---------| +| `name` | Display name of a feature/provider | `integration/docker/name=Docker` | +| `description` | Longer description text | `integration/docker/description=Enables...` | +| `{field}` | Form field label | `vpn/tailscale/auth-key=Tailscale auth key` | +| `{field}-help` | Help text for a field | `certificate/letsencrypt/dns/acmedns/allow-list-help=Comma-separated...` | +| `in-use` | Resource is being used | `core/cache/in-use=Cache is in use...` | +| `not-found` | Resource was not found | `core/user/not-found=User not found` | +| `invalid-{thing}` | Validation: invalid value | `core/binding/invalid-ip=Value is not a valid IP` | +| `{thing}-required` | Validation: required field | `vpn/tailscale/auth-key-required=Auth key is required` | + +## Value Guidelines + +### 1. Placeholder Variables + +Use `${variable}` syntax for dynamic values: + +```properties +core/cache/invalid-status-code=Invalid status code ${value}: must be between ${min} and ${max} +core/host/duplicated-route-priority=Priority ${priority} is duplicated +``` + +### 2. Text Style + +- Use sentence case (capitalize first word only, except proper nouns) +- Use consistent terminology throughout +- Keep messages user-friendly and actionable +- For error messages, explain what went wrong and ideally how to fix it + +### 3. Capitalization + +- **Do not use uppercase letters at will**: Follow standard capitalization rules for the language. +- **Sentence case**: Always use sentence case for UI labels and messages. Use uppercase only at the beginning of the sentence or for proper nouns (e.g. brand names, technical terms like 'Nginx'). +- **Avoid Title Case**: Do not capitalize every word. E.g., use "Upstream server" instead of "Upstream Server". +- **Context matters**: Ensure capitalization fits the grammatical context. + +### 4. Length Considerations + +- UI labels: Keep short (1-3 words) +- Error messages: Be descriptive but concise +- Help text: Be descriptive but concise + +## Adding New Keys + +### Step 1: Determine the Folder Location + +Find where in the codebase the key will be used. The folder path becomes the key prefix. + +### Step 2: Create a Descriptive Suffix + +Choose a suffix that: +- Describes the purpose +- Does NOT include words already in the path +- Follows existing patterns in the same folder + +### Step 3: Add to the File + +Add the new key in the file. You don't need to worry where to place it, just add it at the bottom of the file, at the +top or anything else (whichever is the fastest one) and the `make generate-i18n` command will sort it automatically. + +### Step 4: Generate Code + +After adding keys, run the code generation: +```bash +make .generate-i18n-files +``` + +This generates: +- `i18n/keys.generated.go` - Go constants +- `i18n/en.generated.go` - Go dictionary +- `frontend/src/core/i18n/model/MessageKey.generated.ts` - TypeScript enum + +## Critical Rules + +### 🚨 NEVER Use Raw Key Strings in Code + +All message keys referenced in Go or frontend TypeScript must use generated constants — never raw path strings. + +**Go** — use `i18n.K.*` from `i18n/keys.generated.go`: + +```go +// ✅ CORRECT +i18n.M(ctx, i18n.K.CoreUserNotFound) +i18n.DetachedMessage{Key: i18n.K.CoreNotificationCategoryCertificateRenewed} + +// ❌ WRONG +i18n.M(ctx, "core/user/not-found") +i18n.DetachedMessage{Key: "core/notification/category/certificate-renewed"} +``` + +**Frontend** — use `MessageKey.*` from `frontend/src/core/i18n/model/MessageKey.generated.ts`: + +```tsx +// ✅ CORRECT + +const text = i18n(MessageKey.FrontendUserNewButton) + +// ❌ WRONG + + +const text = i18n("frontend/user/new-button") +``` + +Use `i18n.Static("...")` only for non-localized text (test fixtures, dynamic values) — not for keys in +`.properties` files. + +### 🚨 NEVER Use Dots in Keys + +Keys use `/` as separator, NOT `.`: +```properties +# ✅ CORRECT +core/user/not-found=User not found + +# ❌ WRONG +core.user.not-found=User not found +``` + +### 🚨 NEVER Duplicate Keys + +Each key must be unique. Before adding a new key, search the file to ensure it doesn't already exist. + +### 🚨 NEVER duplicate values + +Each value must be unique. If the i18n generator fails with a message that a value already exists, +then move the message to the `common/` key group/prefix. Do not reuse the key that already exists, if a message is used +twice in different folders/places, moving it to the `common/` must be done. + +Important: ONLY USE common/ IF DUPLICATED. Otherwise, create new keys in the appropriate folder. + +### 🚨 Keep Values on Same Line + +Do not break values across multiple lines. Each key=value pair must be on a single line. + +## File Organization + +The file is organized by module/feature area: + +1. **Core modules** (`core/...`) - Backend business logic +2. **Frontend** (`frontend/...`) - UI-specific text +3. **API** (`api/...`) - API layer messages +4. **Certificate** (`certificate/...`) - Certificate management +5. **Integration** (`integration/...`) - Third-party integrations +6. **VPN** (`vpn/...`) - VPN features +7. **Database** (`database/...`) - Database layer +8. **Common** (`common/...`) - Shared across modules + +## Localization + +This file (`messages_en.properties`) is the source of truth. Other locale files should: +- Have the SAME keys in the SAME order +- Only differ in the values (translated text) + +### Available Languages + +| File | Language | Dialect/Variant | Script | Region/Notes | +|------|----------|-----------------|--------|--------------| +| `messages_en.properties` | English | Standard | Latin | Source of truth | +| `messages_bn.properties` | Bengali | Standard | Bengali (বাংলা) | Bangladesh/India | +| `messages_de.properties` | German | Standard | Latin | Germany/Austria/Switzerland | +| `messages_es.properties` | Spanish | Standard | Latin | General Spanish | +| `messages_fr.properties` | French | Standard | Latin | France/General French | +| `messages_hi.properties` | Hindi | Standard | Devanagari (हिंदी) | India | +| `messages_ja.properties` | Japanese | Standard | Kanji/Hiragana/Katakana | Japan | +| `messages_pt.properties` | Portuguese | Brazilian (pt-BR) | Latin | Brazil | +| `messages_ru.properties` | Russian | Standard | Cyrillic (русский) | Russia | +| `messages_vi.properties` | Vietnamese | Standard | Latin with diacritics | Vietnam | +| `messages_zh.properties` | Mandarin Chinese | Simplified (zh-CN) | Simplified Hanzi (简体中文) | Mainland China (PRC) | + +### Script Notes + +- **CJK Languages**: Japanese (`ja`) and Chinese (`zh`) use complex character sets +- **Indic Languages**: Bengali (`bn`) uses the Bengali script derived from Brahmi; Hindi (`hi`) uses Devanagari script + +## Usage in Code + +### Go Backend + +```go +import "dillmann.com.br/nginx-ignition/i18n" + +// Get a message +msg := i18n.M(ctx, i18n.K.CoreAccesslistInUse) + +// With parameters +msg := i18n.M(ctx, i18n.K.CoreCacheInvalidStatusCode, + i18n.P("value", "abc"), + i18n.P("min", 100), + i18n.P("max", 599)) +``` + +### TypeScript Frontend + +```typescript +import MessageKey from "@/core/i18n/model/MessageKey.generated" +import { i18n } from "@/core/i18n/I18n" + +// Get a message +const text = i18n(MessageKey.CoreAccesslistInUse) + +// In JSX + +``` + +Never pass raw key path strings in `` or `i18n(...)` — always use `MessageKey.*` constants +(see **NEVER Use Raw Key Strings in Code** above). + +Important frontend notes: +- Always use the `I18N` component. Only use `i18n()` function if the output is required to be a `string`. +- A param must be a type that can be rendered as a string, like a number, boolean, string itself, etc. A param cannot be + a dynamic value (e.g. a component, function, link, HTML tag and so on). If you need to render a dynamic value in the + middle of a message, split the message into two or more parts. + +## Summary Checklist + +When adding or modifying keys: + +- [ ] Key prefix matches the folder where it's used +- [ ] Suffix is descriptive but not redundant +- [ ] No dots in the key (use `/` only) +- [ ] Value uses `${var}` for placeholders +- [ ] Key is unique (no duplicates) +- [ ] Key stays on its original line (if editing) +- [ ] Go code references the key via `i18n.K.*` — never a raw string literal +- [ ] Frontend code references the key via `MessageKey.*` — never a raw string literal +- [ ] Run `make generate-i18n` after changes diff --git a/i18n/go.mod b/i18n/go.mod index 67f5386a0..aa7d3bf8a 100644 --- a/i18n/go.mod +++ b/i18n/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/i18n -go 1.26.3 +go 1.26.4 require ( go.uber.org/mock v0.6.0 diff --git a/i18n/messages_bn.properties b/i18n/messages_bn.properties index 3ad7246c7..0ed4b2e3d 100644 --- a/i18n/messages_bn.properties +++ b/i18n/messages_bn.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx চলছে না core/nginx/stats-fetch-failed=ট্রাফিক পরিসংখ্যান আনতে ব্যর্থ core/nginx/stats-not-enabled=ট্রাফিক পরিসংখ্যান সক্ষম নয় core/nginx/version-check-failed=Nginx ভার্সন চেক করতে ব্যর্থ হয়েছে +core/notification/category/certificate-expiring=সার্টিফিকেট মেয়াদোত্তীর্ণ হচ্ছে +core/notification/category/certificate-renew-failed=সার্টিফিকেট নবায়ন ব্যর্থ +core/notification/category/certificate-renewed=সার্টিফিকেট নবায়ন সম্পন্ন +core/notification/category/nginx-reload-failed=Nginx রিলোড ব্যর্থ +core/notification/category/nginx-reload-succeeded=Nginx রিলোড সফল +core/notification/category/unknown=অজানা নোটিফিকেশন বিভাগ +core/notification/configuration-not-found=নোটিফিকেশন কনফিগারেশন পাওয়া যায়নি +core/notification/duplicated-name=এই নামে একটি কনফিগারেশন ইতিমধ্যে বিদ্যমান +core/notification/invalid-category=নোটিফিকেশন বিভাগ বৈধ নয় +core/notification/not-found=নোটিফিকেশন পাওয়া যায়নি core/settings/invalid-extension=পাথটি অবশ্যই "${extension}" দিয়ে শেষ হতে হবে core/settings/invalid-folder=পাথটি অবশ্যই একটি বিদ্যমান ফোল্ডার হতে হবে core/stream/at-least-one-backend=রাউটে অন্তত একটি ব্যাকএন্ড থাকতে হবে @@ -922,6 +932,7 @@ core/user/invalid-credentials=অবৈধ ইউজারনেম বা প core/user/invalid-totp-code=অবৈধ TOTP কোড core/user/not-found-by-id=প্রদত্ত ID দিয়ে কোন ইউজার পাওয়া যায়নি core/user/not-found=ইউজার পাওয়া যায়নি +core/user/notification-language=নোটিফিকেশন ভাষা core/user/password-reset-mode=অ্যাপ্লিকেশনটি পাসওয়ার্ড রিসেট পদ্ধতি ব্যবহার করে শুরু করা হয়েছিল। চালিয়ে যাওয়ার জন্য দয়া করে এটি নিষ্ক্রিয় করুন। core/user/too-short=অন্তত ${min} অক্ষর থাকতে হবে core/user/totp-not-enabled=এই ব্যবহারকারীর জন্য TOTP চালু করা হয়নি @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=${id} এর জন্য প্ integration/truenas/proxy-url=অ্যাপস URL integration/truenas/url-help=যে URL-এ আপনার NAS অ্যাক্সেসযোগ্য, যেমন http://192.168.0.2 বা https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=সার্টিফিকেট দেখুন +notification/event/certificate-expiring/section-body=সার্টিফিকেট ${domain} ${expiresAt}-এ মেয়াদোত্তীর্ণ হবে। +notification/event/certificate-expiring/summary=সার্টিফিকেট ${domain} শীঘ্রই মেয়াদোত্তীর্ণ হচ্ছে। +notification/event/certificate-expiring/title=সার্টিফিকেট মেয়াদোত্তীর্ণ হচ্ছে: ${domain} +notification/event/certificate-renew-failed/action-label=সার্টিফিকেট দেখুন +notification/event/certificate-renew-failed/section-body=${domain}-এর স্বয়ংক্রিয় নবায়ন ব্যর্থ: ${reason} +notification/event/certificate-renew-failed/summary=${domain}-এর নবায়ন ব্যর্থ +notification/event/certificate-renew-failed/title=সার্টিফিকেট নবায়ন ব্যর্থ: ${domain} +notification/event/certificate-renewed/action-label=সার্টিফিকেট দেখুন +notification/event/certificate-renewed/section-body=সার্টিফিকেট ${domain} সফলভাবে নবায়ন করা হয়েছে। +notification/event/certificate-renewed/summary=সার্টিফিকেট ${domain} নবায়ন করা হয়েছে +notification/event/certificate-renewed/title=সার্টিফিকেট নবায়ন করা হয়েছে: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx রিলোড করতে ব্যর্থ: ${reason} +notification/event/nginx-reload-failed/summary=Nginx নতুন কনফিগারেশন প্রয়োগ করতে পারেনি +notification/event/nginx-reload-failed/title=Nginx রিলোড করা যায়নি +notification/event/nginx-reload-succeeded/summary=Nginx সফলভাবে রিলোড হয়েছে +notification/event/nginx-reload-succeeded/title=Nginx রিলোড সম্পন্ন হয়েছে +notification/smtp/from=প্রেরকের ঠিকানা +notification/smtp/host=SMTP হোস্ট +notification/smtp/instruction-app-password=Gmail এবং অনুরূপ প্রদানকারীদের জন্য, আপনার অ্যাকাউন্ট পাসওয়ার্ডের পরিবর্তে অ্যাপ-নির্দিষ্ট পাসওয়ার্ড ব্যবহার করুন। +notification/smtp/instruction-tls=পোর্ট 587-এ STARTTLS বা পোর্ট 465-এ অন্তর্নিহিত TLS (SMTPS) সক্রিয় করুন। একসাথে উভয় TLS মোড সক্রিয় করবেন না। +notification/smtp/name=SMTP +notification/smtp/password=পাসওয়ার্ড +notification/smtp/port=SMTP পোর্ট +notification/smtp/to-help=কমা দ্বারা পৃথক ইমেল ঠিকানা +notification/smtp/to=প্রাপক +notification/smtp/use-start-tls=STARTTLS ব্যবহার করুন +notification/smtp/use-tls=TLS (SMTPS) ব্যবহার করুন +notification/smtp/username=ইউজারনেম vpn/netbird/instruction-key-settings=কী তৈরি করার সময়, পুনঃব্যবহারযোগ্য (Reusable) প্রকার নির্বাচন করতে, Ephemeral Peers বিকল্প সক্রিয় করতে, এবং মেয়াদ উত্তীর্ণের তারিখ ছাড়া ও সীমাহীন ব্যবহারের সংখ্যা সহ এটি কনফিগার করতে ভুলবেন না। অন্যথায়, nginx ignition সঠিকভাবে নেটওয়ার্কে ভার্চুয়াল ডিভাইসগুলি পরিচালনা এবং নিবন্ধন করতে পারবে না। vpn/netbird/instruction-setup-key=একটি সেটআপ কী NetBird ড্যাশবোর্ডে "Setup Keys" এর অধীনে তৈরি করা যেতে পারে। vpn/netbird/management-url-help=কাস্টম ম্যানেজমেন্ট সার্ভার URL। ডিফল্ট (api.netbird.io) ব্যবহার করতে ফাঁকা রাখুন। diff --git a/i18n/messages_de.properties b/i18n/messages_de.properties index c0ec0e0fa..eff75c9b9 100644 --- a/i18n/messages_de.properties +++ b/i18n/messages_de.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx läuft nicht core/nginx/stats-fetch-failed=Fehler beim Abrufen der Verkehrsstatistiken core/nginx/stats-not-enabled=Verkehrsstatistiken sind nicht aktiviert core/nginx/version-check-failed=Fehler beim Prüfen der Nginx-Version +core/notification/category/certificate-expiring=Zertifikat läuft ab +core/notification/category/certificate-renew-failed=Zertifikatserneuerung fehlgeschlagen +core/notification/category/certificate-renewed=Zertifikatserneuerung abgeschlossen +core/notification/category/nginx-reload-failed=Nginx-Neuladung fehlgeschlagen +core/notification/category/nginx-reload-succeeded=Nginx-Neuladung erfolgreich +core/notification/category/unknown=Unbekannte Benachrichtigungskategorie +core/notification/configuration-not-found=Benachrichtigungskonfiguration nicht gefunden +core/notification/duplicated-name=Es existiert bereits eine Konfiguration mit diesem Namen +core/notification/invalid-category=Benachrichtigungskategorie ist ungültig +core/notification/not-found=Benachrichtigung nicht gefunden core/settings/invalid-extension=Pfad muss mit "${extension}" enden core/settings/invalid-folder=Pfad muss auf einen existierenden Ordner zeigen core/stream/at-least-one-backend=Route muss mindestens ein Backend haben @@ -922,6 +932,7 @@ core/user/invalid-credentials=Ungültiger Benutzername oder Passwort core/user/invalid-totp-code=Ungültiger TOTP-Code core/user/not-found-by-id=Kein Benutzer mit der angegebenen ID gefunden core/user/not-found=Benutzer nicht gefunden +core/user/notification-language=Benachrichtigungssprache core/user/password-reset-mode=Die Anwendung wurde mit dem Verfahren zum Zurücksetzen des Passworts gestartet. Bitte deaktivieren Sie es, um fortzufahren. core/user/too-short=Sollte mindestens ${min} Zeichen haben core/user/totp-not-enabled=TOTP ist für diesen Benutzer nicht aktiviert @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=Proxy-URL für ${id} konnte nich integration/truenas/proxy-url=Apps URL integration/truenas/url-help=Die URL, unter der Ihr NAS erreichbar ist, wie http://192.168.0.2 oder https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=Zertifikat anzeigen +notification/event/certificate-expiring/section-body=Das Zertifikat ${domain} läuft am ${expiresAt} ab. +notification/event/certificate-expiring/summary=Das Zertifikat ${domain} läuft bald ab. +notification/event/certificate-expiring/title=Zertifikat läuft ab: ${domain} +notification/event/certificate-renew-failed/action-label=Zertifikat anzeigen +notification/event/certificate-renew-failed/section-body=Automatische Erneuerung fehlgeschlagen für ${domain}: ${reason} +notification/event/certificate-renew-failed/summary=Erneuerung fehlgeschlagen für ${domain} +notification/event/certificate-renew-failed/title=Zertifikatserneuerung fehlgeschlagen: ${domain} +notification/event/certificate-renewed/action-label=Zertifikat anzeigen +notification/event/certificate-renewed/section-body=Das Zertifikat ${domain} wurde erfolgreich erneuert. +notification/event/certificate-renewed/summary=Das Zertifikat ${domain} wurde erneuert +notification/event/certificate-renewed/title=Zertifikat erneuert: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx konnte nicht neu geladen werden: ${reason} +notification/event/nginx-reload-failed/summary=Nginx konnte die neue Konfiguration nicht anwenden +notification/event/nginx-reload-failed/title=Nginx-Neuladung ist fehlgeschlagen +notification/event/nginx-reload-succeeded/summary=Nginx wurde erfolgreich neu geladen +notification/event/nginx-reload-succeeded/title=Nginx-Neuladung abgeschlossen +notification/smtp/from=Absenderadresse +notification/smtp/host=SMTP-Host +notification/smtp/instruction-app-password=Für Gmail und ähnliche Anbieter verwenden Sie ein app-spezifisches Passwort anstelle Ihres Kontopassworts. +notification/smtp/instruction-tls=Aktivieren Sie STARTTLS auf Port 587 oder implizites TLS (SMTPS) auf Port 465. Aktivieren Sie nicht beide TLS-Modi gleichzeitig. +notification/smtp/name=SMTP +notification/smtp/password=Passwort +notification/smtp/port=SMTP-Port +notification/smtp/to-help=Kommagetrennte E-Mail-Adressen +notification/smtp/to=Empfänger +notification/smtp/use-start-tls=STARTTLS verwenden +notification/smtp/use-tls=TLS (SMTPS) verwenden +notification/smtp/username=Benutzername vpn/netbird/instruction-key-settings=Stellen Sie bei der Generierung des Schlüssels sicher, dass Sie den Typ Wiederverwendbar (Reusable) auswählen, die Option Ephemere Peers aktivieren und ihn ohne Ablaufdatum sowie mit unbegrenzter Nutzungsanzahl konfigurieren. Andernfalls kann nginx ignition die virtuellen Geräte im Netzwerk nicht ordnungsgemäß verwalten und registrieren. vpn/netbird/instruction-setup-key=Ein Setup-Schlüssel kann im NetBird-Dashboard unter "Setup Keys" generiert werden. vpn/netbird/management-url-help=Benutzerdefinierte URL des Management-Servers. Leer lassen, um den Standard zu verwenden (api.netbird.io). diff --git a/i18n/messages_en.properties b/i18n/messages_en.properties index c96565141..ceba973ae 100644 --- a/i18n/messages_en.properties +++ b/i18n/messages_en.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx is not running core/nginx/stats-fetch-failed=Failed to fetch traffic statistics core/nginx/stats-not-enabled=Traffic statistics are not enabled core/nginx/version-check-failed=Failed to check Nginx version +core/notification/category/certificate-expiring=Certificate expiring +core/notification/category/certificate-renew-failed=Certificate renew failed +core/notification/category/certificate-renewed=Certificate renewal completed +core/notification/category/nginx-reload-failed=Nginx reload failure +core/notification/category/nginx-reload-succeeded=Nginx reload success +core/notification/category/unknown=Unknown notification category +core/notification/configuration-not-found=Notification configuration not found +core/notification/duplicated-name=A configuration with this name already exists +core/notification/invalid-category=Notification category is not valid +core/notification/not-found=Notification not found core/settings/invalid-extension=Path must end with "${extension}" core/settings/invalid-folder=Path must point to an existing folder core/stream/at-least-one-backend=Route must have at least one backend @@ -922,6 +932,7 @@ core/user/invalid-credentials=Invalid username or password core/user/invalid-totp-code=Invalid TOTP code core/user/not-found-by-id=No user found with provided ID core/user/not-found=User not found +core/user/notification-language=Notification language core/user/password-reset-mode=Application was started using the password reset procedure. Please disable it in order to continue. core/user/too-short=Should have at least ${min} characters core/user/totp-not-enabled=TOTP is not enabled for this user @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=Unable to resolve proxy URL for integration/truenas/proxy-url=Apps URL integration/truenas/url-help=The URL where your NAS is accessible, like http://192.168.0.2 or https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=View certificate +notification/event/certificate-expiring/section-body=Certificate ${domain} expires on ${expiresAt}. +notification/event/certificate-expiring/summary=Certificate ${domain} is expiring soon. +notification/event/certificate-expiring/title=Certificate expiring: ${domain} +notification/event/certificate-renew-failed/action-label=View certificate +notification/event/certificate-renew-failed/section-body=Automatic renewal failed for ${domain}: ${reason} +notification/event/certificate-renew-failed/summary=Renewal failed for ${domain} +notification/event/certificate-renew-failed/title=Certificate renew failed: ${domain} +notification/event/certificate-renewed/action-label=View certificate +notification/event/certificate-renewed/section-body=Certificate ${domain} was renewed successfully. +notification/event/certificate-renewed/summary=Certificate ${domain} was renewed +notification/event/certificate-renewed/title=Certificate renewed: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx failed to reload: ${reason} +notification/event/nginx-reload-failed/summary=Nginx could not apply the new configuration +notification/event/nginx-reload-failed/title=Nginx reload failed +notification/event/nginx-reload-succeeded/summary=Nginx reloaded successfully +notification/event/nginx-reload-succeeded/title=Nginx reload succeeded +notification/smtp/from=From address +notification/smtp/host=SMTP host +notification/smtp/instruction-app-password=For Gmail and similar providers, use an app-specific password instead of your account password. +notification/smtp/instruction-tls=Enable STARTTLS on port 587 or implicit TLS (SMTPS) on port 465. Do not enable both TLS modes at once. +notification/smtp/name=SMTP +notification/smtp/password=Password +notification/smtp/port=SMTP port +notification/smtp/to-help=Comma-separated email addresses +notification/smtp/to=Recipients +notification/smtp/use-start-tls=Use STARTTLS +notification/smtp/use-tls=Use TLS (SMTPS) +notification/smtp/username=Username vpn/netbird/instruction-key-settings=When generating the key, make sure to select the Reusable type, enable Ephemeral Peers option, and configure it with no expiry date and unlimited usage count. Otherwise, nginx ignition will not be able to properly manage and register virtual devices in the network. vpn/netbird/instruction-setup-key=A setup key can be generated in the NetBird dashboard under Setup Keys. vpn/netbird/management-url-help=Custom management server URL. Leave empty to use the default (api.netbird.io). diff --git a/i18n/messages_es.properties b/i18n/messages_es.properties index 6ff7fbcef..834742910 100644 --- a/i18n/messages_es.properties +++ b/i18n/messages_es.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx no se está ejecutando core/nginx/stats-fetch-failed=Error al obtener estadísticas de tráfico core/nginx/stats-not-enabled=Las estadísticas de tráfico no están habilitadas core/nginx/version-check-failed=Error al comprobar la versión de Nginx +core/notification/category/certificate-expiring=Certificado por expirar +core/notification/category/certificate-renew-failed=Error al renovar el certificado +core/notification/category/certificate-renewed=Renovación del certificado completada +core/notification/category/nginx-reload-failed=Error al recargar nginx +core/notification/category/nginx-reload-succeeded=Recarga de nginx exitosa +core/notification/category/unknown=Categoría de notificación desconocida +core/notification/configuration-not-found=Configuración de notificación no encontrada +core/notification/duplicated-name=Ya existe una configuración con este nombre +core/notification/invalid-category=La categoría de notificación no es válida +core/notification/not-found=Notificación no encontrada core/settings/invalid-extension=La ruta debe terminar con "${extension}" core/settings/invalid-folder=La ruta debe apuntar a una carpeta existente core/stream/at-least-one-backend=La ruta debe tener al menos un backend @@ -922,6 +932,7 @@ core/user/invalid-credentials=Nombre de usuario o contraseña inválidos core/user/invalid-totp-code=Código TOTP inválido core/user/not-found-by-id=No se encontró ningún usuario con el ID proporcionado core/user/not-found=Usuario no encontrado +core/user/notification-language=Idioma de las notificaciones core/user/password-reset-mode=La aplicación se inició utilizando el procedimiento de restablecimiento de contraseña. Por favor, deshabilítelo para continuar. core/user/too-short=Debe tener al menos ${min} caracteres core/user/totp-not-enabled=TOTP no está habilitado para este usuario @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=No se pudo resolver la URL del p integration/truenas/proxy-url=URL de Apps integration/truenas/url-help=La URL donde su NAS es accesible, como http://192.168.0.2 o https://nas.sudominio.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=Ver certificado +notification/event/certificate-expiring/section-body=El certificado ${domain} expira el ${expiresAt}. +notification/event/certificate-expiring/summary=El certificado ${domain} está por expirar pronto. +notification/event/certificate-expiring/title=Certificado por expirar: ${domain} +notification/event/certificate-renew-failed/action-label=Ver certificado +notification/event/certificate-renew-failed/section-body=La renovación automática falló para ${domain}: ${reason} +notification/event/certificate-renew-failed/summary=Error en la renovación de ${domain} +notification/event/certificate-renew-failed/title=Error al renovar el certificado: ${domain} +notification/event/certificate-renewed/action-label=Ver certificado +notification/event/certificate-renewed/section-body=El certificado ${domain} fue renovado correctamente. +notification/event/certificate-renewed/summary=El certificado ${domain} fue renovado +notification/event/certificate-renewed/title=Certificado renovado: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx no pudo recargar: ${reason} +notification/event/nginx-reload-failed/summary=Nginx no pudo aplicar la nueva configuración +notification/event/nginx-reload-failed/title=La recarga de nginx falló +notification/event/nginx-reload-succeeded/summary=Nginx recargado correctamente +notification/event/nginx-reload-succeeded/title=Recarga de nginx completada +notification/smtp/from=Dirección de origen +notification/smtp/host=Host SMTP +notification/smtp/instruction-app-password=Para Gmail y proveedores similares, use una contraseña de aplicación en lugar de la contraseña de su cuenta. +notification/smtp/instruction-tls=Habilite STARTTLS en el puerto 587 o TLS implícito (SMTPS) en el puerto 465. No habilite ambos modos TLS a la vez. +notification/smtp/name=SMTP +notification/smtp/password=Contraseña +notification/smtp/port=Puerto SMTP +notification/smtp/to-help=Direcciones de correo separadas por comas +notification/smtp/to=Destinatarios +notification/smtp/use-start-tls=Usar STARTTLS +notification/smtp/use-tls=Usar TLS (SMTPS) +notification/smtp/username=Nombre de usuario vpn/netbird/instruction-key-settings=Al generar la clave, asegúrese de seleccionar el tipo Reutilizable (Reusable), habilitar la opción de Pares Efímeros (Ephemeral Peers) y configurarla sin fecha de caducidad y con un recuento de uso ilimitado. De lo contrario, nginx ignition no podrá gestionar ni registrar correctamente los dispositivos virtuales en la red. vpn/netbird/instruction-setup-key=Puede generar una clave de configuración (setup key) en el panel de NetBird en "Setup Keys". vpn/netbird/management-url-help=URL del servidor de gestión personalizado. Déjelo en blanco para usar el predeterminado (api.netbird.io). diff --git a/i18n/messages_fr.properties b/i18n/messages_fr.properties index 106b7454b..dd431e7f4 100644 --- a/i18n/messages_fr.properties +++ b/i18n/messages_fr.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx ne fonctionne pas core/nginx/stats-fetch-failed=Échec de la récupération des statistiques de trafic core/nginx/stats-not-enabled=Les statistiques de trafic ne sont pas activées core/nginx/version-check-failed=Échec de la vérification de la version Nginx +core/notification/category/certificate-expiring=Certificat expirant +core/notification/category/certificate-renew-failed=Échec du renouvellement du certificat +core/notification/category/certificate-renewed=Renouvellement du certificat terminé +core/notification/category/nginx-reload-failed=Échec du rechargement nginx +core/notification/category/nginx-reload-succeeded=Rechargement nginx réussi +core/notification/category/unknown=Catégorie de notification inconnue +core/notification/configuration-not-found=Configuration de notification introuvable +core/notification/duplicated-name=Une configuration portant ce nom existe déjà +core/notification/invalid-category=La catégorie de notification n'est pas valide +core/notification/not-found=Notification introuvable core/settings/invalid-extension=Le chemin doit se terminer par "${extension}" core/settings/invalid-folder=Le chemin doit pointer vers un dossier existant core/stream/at-least-one-backend=La route doit avoir au moins un backend @@ -922,6 +932,7 @@ core/user/invalid-credentials=Nom d'utilisateur ou mot de passe invalide core/user/invalid-totp-code=Code TOTP invalide core/user/not-found-by-id=Aucun utilisateur trouvé avec l'ID fourni core/user/not-found=Utilisateur introuvable +core/user/notification-language=Langue des notifications core/user/password-reset-mode=L'application a été démarrée en utilisant la procédure de réinitialisation de mot de passe. Veuillez la désactiver pour continuer. core/user/too-short=Doit avoir au moins ${min} caractères core/user/totp-not-enabled=TOTP n'est pas activé pour cet utilisateur @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=Impossible de résoudre l'URL pr integration/truenas/proxy-url=URL des Apps integration/truenas/url-help=L'URL où votre NAS est accessible, comme http://192.168.0.2 ou https://nas.votredomaine.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=Voir le certificat +notification/event/certificate-expiring/section-body=Le certificat ${domain} expire le ${expiresAt}. +notification/event/certificate-expiring/summary=Le certificat ${domain} expire bientôt. +notification/event/certificate-expiring/title=Certificat expirant : ${domain} +notification/event/certificate-renew-failed/action-label=Voir le certificat +notification/event/certificate-renew-failed/section-body=Le renouvellement automatique a échoué pour ${domain} : ${reason} +notification/event/certificate-renew-failed/summary=Échec du renouvellement pour ${domain} +notification/event/certificate-renew-failed/title=Échec du renouvellement du certificat : ${domain} +notification/event/certificate-renewed/action-label=Voir le certificat +notification/event/certificate-renewed/section-body=Le certificat ${domain} a été renouvelé avec succès. +notification/event/certificate-renewed/summary=Le certificat ${domain} a été renouvelé +notification/event/certificate-renewed/title=Certificat renouvelé : ${domain} +notification/event/nginx-reload-failed/section-body=Nginx n'a pas pu recharger : ${reason} +notification/event/nginx-reload-failed/summary=Nginx n'a pas pu appliquer la nouvelle configuration +notification/event/nginx-reload-failed/title=Le rechargement nginx a échoué +notification/event/nginx-reload-succeeded/summary=Nginx a été rechargé avec succès +notification/event/nginx-reload-succeeded/title=Rechargement nginx terminé +notification/smtp/from=Adresse d'expéditeur +notification/smtp/host=Hôte SMTP +notification/smtp/instruction-app-password=Pour Gmail et les fournisseurs similaires, utilisez un mot de passe d'application au lieu du mot de passe de votre compte. +notification/smtp/instruction-tls=Activez STARTTLS sur le port 587 ou TLS implicite (SMTPS) sur le port 465. N'activez pas les deux modes TLS en même temps. +notification/smtp/name=SMTP +notification/smtp/password=Mot de passe +notification/smtp/port=Port SMTP +notification/smtp/to-help=Adresses e-mail séparées par des virgules +notification/smtp/to=Destinataires +notification/smtp/use-start-tls=Utiliser STARTTLS +notification/smtp/use-tls=Utiliser TLS (SMTPS) +notification/smtp/username=Nom d'utilisateur vpn/netbird/instruction-key-settings=Lors de la génération de la clé, assurez-vous de sélectionner le type Réutilisable (Reusable), d'activer l'option Pairs éphémères (Ephemeral Peers), et de la configurer sans date d'expiration et avec un nombre d'utilisations illimité. Sinon, nginx ignition ne pourra pas gérer et enregistrer correctement les appareils virtuels sur le réseau. vpn/netbird/instruction-setup-key=Une clé de configuration peut être générée dans le tableau de bord NetBird sous "Setup Keys". vpn/netbird/management-url-help=URL du serveur de gestion personnalisé. Laissez vide pour utiliser l'URL par défaut (api.netbird.io). diff --git a/i18n/messages_hi.properties b/i18n/messages_hi.properties index 33d0f8e96..fb5ea67e2 100644 --- a/i18n/messages_hi.properties +++ b/i18n/messages_hi.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx नहीं चल रहा है core/nginx/stats-fetch-failed=ट्रैफ़िक आँकड़े प्राप्त करने में विफल core/nginx/stats-not-enabled=ट्रैफ़िक आँकड़े सक्षम नहीं हैं core/nginx/version-check-failed=Nginx वर्शन चेक करने में विफल +core/notification/category/certificate-expiring=प्रमाणपत्र समाप्त हो रहा है +core/notification/category/certificate-renew-failed=प्रमाणपत्र नवीनीकरण विफल +core/notification/category/certificate-renewed=प्रमाणपत्र नवीनीकरण पूर्ण +core/notification/category/nginx-reload-failed=Nginx रिलोड विफल +core/notification/category/nginx-reload-succeeded=Nginx रिलोड सफल +core/notification/category/unknown=अज्ञात सूचना श्रेणी +core/notification/configuration-not-found=सूचना कॉन्फ़िगरेशन नहीं मिली +core/notification/duplicated-name=इस नाम का कॉन्फ़िगरेशन पहले से मौजूद है +core/notification/invalid-category=सूचना श्रेणी मान्य नहीं है +core/notification/not-found=सूचना नहीं मिली core/settings/invalid-extension=पाथ "${extension}" के साथ समाप्त होना चाहिए core/settings/invalid-folder=पाथ को एक मौजूदा फ़ोल्डर की ओर इंगित करना चाहिए core/stream/at-least-one-backend=रूट में कम से कम एक बैकएंड होना चाहिए @@ -922,6 +932,7 @@ core/user/invalid-credentials=अमान्य यूज़रनेम या core/user/invalid-totp-code=अमान्य TOTP कोड core/user/not-found-by-id=प्रदान की गई ID के साथ कोई यूज़र नहीं मिला core/user/not-found=यूज़र नहीं मिला +core/user/notification-language=सूचना भाषा core/user/password-reset-mode=एप्लिकेशन पासवर्ड रीसेट प्रक्रिया का उपयोग करके शुरू किया गया था। जारी रखने के लिए कृपया इसे अक्षम करें। core/user/too-short=कम से कम ${min} वर्ण होने चाहिए core/user/totp-not-enabled=इस उपयोगकर्ता के लिए TOTP सक्षम नहीं है @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=${id} के लिए प्र integration/truenas/proxy-url=ऐप्स URL integration/truenas/url-help=वह URL जहाँ आपका NAS सुलभ है, जैसे http://192.168.0.2 या https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=प्रमाणपत्र देखें +notification/event/certificate-expiring/section-body=प्रमाणपत्र ${domain} ${expiresAt} को समाप्त होगा। +notification/event/certificate-expiring/summary=प्रमाणपत्र ${domain} जल्द समाप्त हो रहा है। +notification/event/certificate-expiring/title=प्रमाणपत्र समाप्त हो रहा है: ${domain} +notification/event/certificate-renew-failed/action-label=प्रमाणपत्र देखें +notification/event/certificate-renew-failed/section-body=${domain} के लिए स्वचालित नवीनीकरण विफल: ${reason} +notification/event/certificate-renew-failed/summary=${domain} का नवीनीकरण विफल +notification/event/certificate-renew-failed/title=प्रमाणपत्र नवीनीकरण विफल: ${domain} +notification/event/certificate-renewed/action-label=प्रमाणपत्र देखें +notification/event/certificate-renewed/section-body=प्रमाणपत्र ${domain} सफलतापूर्वक नवीनीकृत किया गया। +notification/event/certificate-renewed/summary=प्रमाणपत्र ${domain} नवीनीकृत किया गया +notification/event/certificate-renewed/title=प्रमाणपत्र नवीनीकृत: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx रिलोड करने में विफल: ${reason} +notification/event/nginx-reload-failed/summary=Nginx नई कॉन्फ़िगरेशन लागू नहीं कर सका +notification/event/nginx-reload-failed/title=Nginx रिलोड नहीं हो सका +notification/event/nginx-reload-succeeded/summary=Nginx सफलतापूर्वक रिलोड किया गया +notification/event/nginx-reload-succeeded/title=Nginx रिलोड पूर्ण हुआ +notification/smtp/from=प्रेषक पता +notification/smtp/host=SMTP होस्ट +notification/smtp/instruction-app-password=Gmail और समान प्रदाताओं के लिए, अपने खाते के पासवर्ड के बजाय ऐप-विशिष्ट पासवर्ड का उपयोग करें। +notification/smtp/instruction-tls=पोर्ट 587 पर STARTTLS या पोर्ट 465 पर अंतर्निहित TLS (SMTPS) सक्षम करें। दोनों TLS मोड एक साथ सक्षम न करें। +notification/smtp/name=SMTP +notification/smtp/password=पासवर्ड +notification/smtp/port=SMTP पोर्ट +notification/smtp/to-help=अल्पविराम से अलग किए गए ईमेल पते +notification/smtp/to=प्राप्तकर्ता +notification/smtp/use-start-tls=STARTTLS का उपयोग करें +notification/smtp/use-tls=TLS (SMTPS) का उपयोग करें +notification/smtp/username=यूज़रनेम vpn/netbird/instruction-key-settings=कुंजी जनरेट करते समय, "Reusable" प्रकार चुनना, "Ephemeral Peers" विकल्प सक्षम करना और इसे बिना किसी समाप्ति तिथि और असीमित उपयोग संख्या के कॉन्फ़िगर करना सुनिश्चित करें। अन्यथा, nginx ignition नेटवर्क में वर्चुअल डिवाइस को ठीक से प्रबंधित और पंजीकृत नहीं कर पाएगा। vpn/netbird/instruction-setup-key=एक सेटअप कुंजी NetBird डैशबोर्ड में "Setup Keys" के अंतर्गत उत्पन्न की जा सकती है। vpn/netbird/management-url-help=कस्टम प्रबंधन सर्वर URL। डिफ़ॉल्ट (api.netbird.io) का उपयोग करने के लिए खाली छोड़ दें। diff --git a/i18n/messages_ja.properties b/i18n/messages_ja.properties index a4a3f6ad7..84273e668 100644 --- a/i18n/messages_ja.properties +++ b/i18n/messages_ja.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginxは実行されていません core/nginx/stats-fetch-failed=トラフィック統計の取得に失敗しました core/nginx/stats-not-enabled=トラフィック統計が有効になっていません core/nginx/version-check-failed=Nginxのバージョンチェックに失敗しました +core/notification/category/certificate-expiring=証明書の有効期限切れ間近 +core/notification/category/certificate-renew-failed=証明書の更新に失敗 +core/notification/category/certificate-renewed=証明書の更新が完了 +core/notification/category/nginx-reload-failed=Nginxのリロード失敗 +core/notification/category/nginx-reload-succeeded=Nginxのリロード成功 +core/notification/category/unknown=不明な通知カテゴリ +core/notification/configuration-not-found=通知設定が見つかりません +core/notification/duplicated-name=この名前の設定は既に存在します +core/notification/invalid-category=通知カテゴリが無効です +core/notification/not-found=通知が見つかりません core/settings/invalid-extension=パスは "${extension}" で終わる必要があります core/settings/invalid-folder=パスは既存のフォルダーを指している必要があります core/stream/at-least-one-backend=ルートには少なくとも1つのバックエンドが必要です @@ -922,6 +932,7 @@ core/user/invalid-credentials=ユーザー名またはパスワードが無効 core/user/invalid-totp-code=無効なTOTPコード core/user/not-found-by-id=指定されたIDのユーザーが見つかりません core/user/not-found=ユーザーが見つかりません +core/user/notification-language=通知の言語 core/user/password-reset-mode=アプリケーションはパスワードリセット手順を使用して起動されました。続行するには無効にしてください。 core/user/too-short=少なくとも ${min} 文字である必要があります core/user/totp-not-enabled=このユーザーのTOTPは有効になっていません @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=${id} のプロキシURLを解 integration/truenas/proxy-url=アプリURL integration/truenas/url-help=NASにアクセスできるURL(例: http://192.168.0.2 または https://nas.yourdomain.com) integration/truenas/url=URL +notification/event/certificate-expiring/action-label=証明書を表示 +notification/event/certificate-expiring/section-body=証明書 ${domain} は ${expiresAt} に期限切れになります。 +notification/event/certificate-expiring/summary=証明書 ${domain} の有効期限が近づいています。 +notification/event/certificate-expiring/title=証明書の有効期限切れ間近: ${domain} +notification/event/certificate-renew-failed/action-label=証明書を表示 +notification/event/certificate-renew-failed/section-body=${domain} の自動更新に失敗しました: ${reason} +notification/event/certificate-renew-failed/summary=${domain} の更新に失敗しました +notification/event/certificate-renew-failed/title=証明書の更新に失敗: ${domain} +notification/event/certificate-renewed/action-label=証明書を表示 +notification/event/certificate-renewed/section-body=証明書 ${domain} は正常に更新されました。 +notification/event/certificate-renewed/summary=証明書 ${domain} が更新されました +notification/event/certificate-renewed/title=証明書が更新されました: ${domain} +notification/event/nginx-reload-failed/section-body=Nginxのリロードに失敗しました: ${reason} +notification/event/nginx-reload-failed/summary=Nginxは新しい設定を適用できませんでした +notification/event/nginx-reload-failed/title=Nginxのリロードに失敗 +notification/event/nginx-reload-succeeded/summary=Nginxは正常にリロードされました +notification/event/nginx-reload-succeeded/title=Nginxのリロードが完了しました +notification/smtp/from=送信元アドレス +notification/smtp/host=SMTPホスト +notification/smtp/instruction-app-password=Gmailなどのプロバイダーでは、アカウントのパスワードの代わりにアプリ専用パスワードを使用してください。 +notification/smtp/instruction-tls=ポート587でSTARTTLS、またはポート465で暗黙的TLS(SMTPS)を有効にしてください。両方のTLSモードを同時に有効にしないでください。 +notification/smtp/name=SMTP +notification/smtp/password=パスワード +notification/smtp/port=SMTPポート +notification/smtp/to-help=カンマ区切りのメールアドレス +notification/smtp/to=受信者 +notification/smtp/use-start-tls=STARTTLSを使用 +notification/smtp/use-tls=TLS(SMTPS)を使用 +notification/smtp/username=ユーザー名 vpn/netbird/instruction-key-settings=キーを生成する際は、再利用可能(Reusable)タイプを選択し、エフェメラルピア(Ephemeral Peers)オプションを有効にして、有効期限なし、使用回数無制限で構成してください。そうしないと、nginx ignition はネットワーク上の仮想デバイスを適切に管理・登録できなくなります。 vpn/netbird/instruction-setup-key=セットアップキーは、NetBird ダッシュボードの「Setup Keys」で生成できます。 vpn/netbird/management-url-help=カスタム管理サーバーの URL。デフォルト(api.netbird.io)を使用する場合は空白のままにしてください。 diff --git a/i18n/messages_pt.properties b/i18n/messages_pt.properties index 5b72cc3d8..2613c3c61 100644 --- a/i18n/messages_pt.properties +++ b/i18n/messages_pt.properties @@ -902,6 +902,16 @@ core/nginx/not-running=O nginx não está rodando core/nginx/stats-fetch-failed=Falha ao buscar estatísticas de tráfego core/nginx/stats-not-enabled=Estatísticas de tráfego não estão habilitadas core/nginx/version-check-failed=Falha ao verificar a versão do nginx +core/notification/category/certificate-expiring=Certificado expirando +core/notification/category/certificate-renew-failed=Falha na renovação do certificado +core/notification/category/certificate-renewed=Renovação do certificado concluída +core/notification/category/nginx-reload-failed=Falha ao recarregar o nginx +core/notification/category/nginx-reload-succeeded=Recarregamento do nginx bem-sucedido +core/notification/category/unknown=Categoria de notificação desconhecida +core/notification/configuration-not-found=Configuração de notificação não encontrada +core/notification/duplicated-name=Já existe uma configuração com este nome +core/notification/invalid-category=A categoria de notificação não é válida +core/notification/not-found=Notificação não encontrada core/settings/invalid-extension=O caminho deve terminar com "${extension}" core/settings/invalid-folder=O caminho deve apontar para uma pasta existente core/stream/at-least-one-backend=A rota deve ter pelo menos um backend @@ -922,6 +932,7 @@ core/user/invalid-credentials=Nome de usuário ou senha inválidos core/user/invalid-totp-code=Código TOTP inválido core/user/not-found-by-id=Nenhum usuário encontrado com o ID fornecido core/user/not-found=Usuário não encontrado +core/user/notification-language=Idioma das notificações core/user/password-reset-mode=A aplicação foi iniciada usando o procedimento de redefinição de senha. Por favor, desabilite-o para continuar. core/user/too-short=Deve ter pelo menos ${min} caracteres core/user/totp-not-enabled=TOTP não está habilitado para este usuário @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=Não foi possível resolver URL integration/truenas/proxy-url=URL dos Apps integration/truenas/url-help=A URL onde seu NAS está acessível, como http://192.168.0.2 ou https://nas.seudominio.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=Ver certificado +notification/event/certificate-expiring/section-body=O certificado ${domain} expira em ${expiresAt}. +notification/event/certificate-expiring/summary=O certificado ${domain} está prestes a expirar. +notification/event/certificate-expiring/title=Certificado expirando: ${domain} +notification/event/certificate-renew-failed/action-label=Ver certificado +notification/event/certificate-renew-failed/section-body=A renovação automática falhou para ${domain}: ${reason} +notification/event/certificate-renew-failed/summary=Falha na renovação de ${domain} +notification/event/certificate-renew-failed/title=Falha na renovação do certificado: ${domain} +notification/event/certificate-renewed/action-label=Ver certificado +notification/event/certificate-renewed/section-body=O certificado ${domain} foi renovado com sucesso. +notification/event/certificate-renewed/summary=O certificado ${domain} foi renovado +notification/event/certificate-renewed/title=Certificado renovado: ${domain} +notification/event/nginx-reload-failed/section-body=O nginx falhou ao recarregar: ${reason} +notification/event/nginx-reload-failed/summary=O nginx não conseguiu aplicar a nova configuração +notification/event/nginx-reload-failed/title=Recarregamento do nginx falhou +notification/event/nginx-reload-succeeded/summary=O nginx foi recarregado com sucesso +notification/event/nginx-reload-succeeded/title=Recarregamento do nginx concluído +notification/smtp/from=Endereço de origem +notification/smtp/host=Host SMTP +notification/smtp/instruction-app-password=Para Gmail e provedores similares, use uma senha de aplicativo em vez da senha da sua conta. +notification/smtp/instruction-tls=Habilite STARTTLS na porta 587 ou TLS implícito (SMTPS) na porta 465. Não habilite ambos os modos TLS ao mesmo tempo. +notification/smtp/name=SMTP +notification/smtp/password=Senha +notification/smtp/port=Porta SMTP +notification/smtp/to-help=Endereços de e-mail separados por vírgula +notification/smtp/to=Destinatários +notification/smtp/use-start-tls=Usar STARTTLS +notification/smtp/use-tls=Usar TLS (SMTPS) +notification/smtp/username=Nome de usuário vpn/netbird/instruction-key-settings=Ao gerar a chave, certifique-se de selecionar o tipo Reutilizável (Reusable), ativar a opção Peers Efêmeros (Ephemeral Peers) e configurá-la sem data de expiração e contagem de uso ilimitada. Caso contrário, o nginx ignition não conseguirá gerenciar e registrar adequadamente os dispositivos virtuais na rede. vpn/netbird/instruction-setup-key=Uma chave de configuração pode ser gerada no painel do NetBird em "Setup Keys". vpn/netbird/management-url-help=URL do servidor de gerenciamento personalizado. Deixe em branco para usar o padrão (api.netbird.io). diff --git a/i18n/messages_ru.properties b/i18n/messages_ru.properties index 982502033..727ec4c3b 100644 --- a/i18n/messages_ru.properties +++ b/i18n/messages_ru.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx не запущен core/nginx/stats-fetch-failed=Не удалось получить статистику трафика core/nginx/stats-not-enabled=Статистика трафика не включена core/nginx/version-check-failed=Не удалось проверить версию Nginx +core/notification/category/certificate-expiring=Истекает срок действия сертификата +core/notification/category/certificate-renew-failed=Ошибка обновления сертификата +core/notification/category/certificate-renewed=Обновление сертификата завершено +core/notification/category/nginx-reload-failed=Ошибка перезагрузки nginx +core/notification/category/nginx-reload-succeeded=Перезагрузка nginx успешна +core/notification/category/unknown=Неизвестная категория уведомления +core/notification/configuration-not-found=Конфигурация уведомлений не найдена +core/notification/duplicated-name=Конфигурация с таким именем уже существует +core/notification/invalid-category=Категория уведомления недействительна +core/notification/not-found=Уведомление не найдено core/settings/invalid-extension=Путь должен заканчиваться на "${extension}" core/settings/invalid-folder=Путь должен указывать на существующую папку core/stream/at-least-one-backend=Маршрут должен иметь как минимум один бэкенд @@ -922,6 +932,7 @@ core/user/invalid-credentials=Неверное имя пользователя core/user/invalid-totp-code=Неверный код TOTP core/user/not-found-by-id=Пользователь с указанным ID не найден core/user/not-found=Пользователь не найден +core/user/notification-language=Язык уведомлений core/user/password-reset-mode=Приложение было запущено с использованием процедуры сброса пароля. Пожалуйста, отключите её, чтобы продолжить. core/user/too-short=Должно содержать не менее ${min} символов core/user/totp-not-enabled=TOTP не включен для этого пользователя @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=Не удалось разреш integration/truenas/proxy-url=URL приложений integration/truenas/url-help=URL, по которому доступен ваш NAS, например http://192.168.0.2 или https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=Просмотреть сертификат +notification/event/certificate-expiring/section-body=Срок действия сертификата ${domain} истекает ${expiresAt}. +notification/event/certificate-expiring/summary=Срок действия сертификата ${domain} скоро истечёт. +notification/event/certificate-expiring/title=Истекает срок действия сертификата: ${domain} +notification/event/certificate-renew-failed/action-label=Просмотреть сертификат +notification/event/certificate-renew-failed/section-body=Автоматическое обновление не удалось для ${domain}: ${reason} +notification/event/certificate-renew-failed/summary=Ошибка обновления для ${domain} +notification/event/certificate-renew-failed/title=Ошибка обновления сертификата: ${domain} +notification/event/certificate-renewed/action-label=Просмотреть сертификат +notification/event/certificate-renewed/section-body=Сертификат ${domain} был успешно обновлён. +notification/event/certificate-renewed/summary=Сертификат ${domain} был обновлён +notification/event/certificate-renewed/title=Сертификат обновлён: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx не удалось перезагрузить: ${reason} +notification/event/nginx-reload-failed/summary=Nginx не смог применить новую конфигурацию +notification/event/nginx-reload-failed/title=Перезагрузка nginx не удалась +notification/event/nginx-reload-succeeded/summary=Nginx успешно перезагружен +notification/event/nginx-reload-succeeded/title=Перезагрузка nginx завершена +notification/smtp/from=Адрес отправителя +notification/smtp/host=SMTP-хост +notification/smtp/instruction-app-password=Для Gmail и аналогичных провайдеров используйте пароль приложения вместо пароля учётной записи. +notification/smtp/instruction-tls=Включите STARTTLS на порту 587 или неявный TLS (SMTPS) на порту 465. Не включайте оба режима TLS одновременно. +notification/smtp/name=SMTP +notification/smtp/password=Пароль +notification/smtp/port=Порт SMTP +notification/smtp/to-help=Адреса электронной почты через запятую +notification/smtp/to=Получатели +notification/smtp/use-start-tls=Использовать STARTTLS +notification/smtp/use-tls=Использовать TLS (SMTPS) +notification/smtp/username=Имя пользователя vpn/netbird/instruction-key-settings=При генерации ключа убедитесь, что вы выбрали тип Reusable (Многоразовый), включили опцию Ephemeral Peers (Временные пиры) и настроили его без даты истечения срока действия и с неограниченным количеством использований. В противном случае nginx ignition не сможет должным образом управлять виртуальными устройствами и регистрировать их в сети. vpn/netbird/instruction-setup-key=Ключ установки можно сгенерировать на панели управления NetBird в разделе "Setup Keys". vpn/netbird/management-url-help=Пользовательский URL сервера управления. Оставьте пустым для использования по умолчанию (api.netbird.io). diff --git a/i18n/messages_vi.properties b/i18n/messages_vi.properties index 18abaeb64..858b94632 100644 --- a/i18n/messages_vi.properties +++ b/i18n/messages_vi.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx không đang chạy core/nginx/stats-fetch-failed=Không thể lấy thống kê lưu lượng core/nginx/stats-not-enabled=Thống kê lưu lượng không được bật core/nginx/version-check-failed=Không thể kiểm tra phiên bản Nginx +core/notification/category/certificate-expiring=Chứng chỉ sắp hết hạn +core/notification/category/certificate-renew-failed=Gia hạn chứng chỉ thất bại +core/notification/category/certificate-renewed=Gia hạn chứng chỉ hoàn tất +core/notification/category/nginx-reload-failed=Tải lại nginx thất bại +core/notification/category/nginx-reload-succeeded=Tải lại nginx thành công +core/notification/category/unknown=Danh mục thông báo không xác định +core/notification/configuration-not-found=Không tìm thấy cấu hình thông báo +core/notification/duplicated-name=Đã tồn tại cấu hình với tên này +core/notification/invalid-category=Danh mục thông báo không hợp lệ +core/notification/not-found=Không tìm thấy thông báo core/settings/invalid-extension=Đường dẫn phải kết thúc bằng "${extension}" core/settings/invalid-folder=Đường dẫn phải trỏ đến một thư mục hiện có core/stream/at-least-one-backend=Tuyến đường phải có ít nhất một backend @@ -922,6 +932,7 @@ core/user/invalid-credentials=Tên đăng nhập hoặc mật khẩu không hợ core/user/invalid-totp-code=Mã TOTP không hợp lệ core/user/not-found-by-id=Không tìm thấy người dùng với ID đã cung cấp core/user/not-found=Người dùng không tồn tại +core/user/notification-language=Ngôn ngữ thông báo core/user/password-reset-mode=Ứng dụng được khởi động bằng quy trình đặt lại mật khẩu. Vui lòng tắt nó để tiếp tục. core/user/too-short=Phải có ít nhất ${min} ký tự core/user/totp-not-enabled=TOTP chưa được kích hoạt cho người dùng này @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=Không thể phân giải proxy integration/truenas/proxy-url=Apps URL integration/truenas/url-help=URL nơi NAS của bạn có thể truy cập được, như http://192.168.0.2 hoặc https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=Xem chứng chỉ +notification/event/certificate-expiring/section-body=Chứng chỉ ${domain} hết hạn vào ${expiresAt}. +notification/event/certificate-expiring/summary=Chứng chỉ ${domain} sắp hết hạn. +notification/event/certificate-expiring/title=Chứng chỉ sắp hết hạn: ${domain} +notification/event/certificate-renew-failed/action-label=Xem chứng chỉ +notification/event/certificate-renew-failed/section-body=Gia hạn tự động thất bại cho ${domain}: ${reason} +notification/event/certificate-renew-failed/summary=Gia hạn thất bại cho ${domain} +notification/event/certificate-renew-failed/title=Gia hạn chứng chỉ thất bại: ${domain} +notification/event/certificate-renewed/action-label=Xem chứng chỉ +notification/event/certificate-renewed/section-body=Chứng chỉ ${domain} đã được gia hạn thành công. +notification/event/certificate-renewed/summary=Chứng chỉ ${domain} đã được gia hạn +notification/event/certificate-renewed/title=Chứng chỉ đã gia hạn: ${domain} +notification/event/nginx-reload-failed/section-body=Nginx không thể tải lại: ${reason} +notification/event/nginx-reload-failed/summary=Nginx không thể áp dụng cấu hình mới +notification/event/nginx-reload-failed/title=Việc tải lại nginx đã thất bại +notification/event/nginx-reload-succeeded/summary=Nginx đã được tải lại thành công +notification/event/nginx-reload-succeeded/title=Tải lại nginx hoàn tất +notification/smtp/from=Địa chỉ người gửi +notification/smtp/host=Máy chủ SMTP +notification/smtp/instruction-app-password=Với Gmail và các nhà cung cấp tương tự, hãy dùng mật khẩu ứng dụng thay vì mật khẩu tài khoản. +notification/smtp/instruction-tls=Bật STARTTLS trên cổng 587 hoặc TLS ngầm (SMTPS) trên cổng 465. Không bật cả hai chế độ TLS cùng lúc. +notification/smtp/name=SMTP +notification/smtp/password=Mật khẩu +notification/smtp/port=Cổng SMTP +notification/smtp/to-help=Địa chỉ email phân tách bằng dấu phẩy +notification/smtp/to=Người nhận +notification/smtp/use-start-tls=Dùng STARTTLS +notification/smtp/use-tls=Dùng TLS (SMTPS) +notification/smtp/username=Tên đăng nhập vpn/netbird/instruction-key-settings=Khi tạo khóa, hãy đảm bảo chọn loại Có thể tái sử dụng (Reusable), bật tùy chọn Các máy ngang hàng tạm thời (Ephemeral Peers), và cấu hình không có ngày hết hạn, cùng số lần sử dụng không giới hạn. Nếu không, nginx ignition sẽ không thể quản lý và đăng ký đúng cách các thiết bị ảo trong mạng. vpn/netbird/instruction-setup-key=Khóa thiết lập có thể được tạo trong bảng điều khiển NetBird ở mục "Setup Keys". vpn/netbird/management-url-help=URL máy chủ quản lý tùy chỉnh. Để trống để sử dụng mặc định (api.netbird.io). diff --git a/i18n/messages_zh.properties b/i18n/messages_zh.properties index 03c1caab4..32364594b 100644 --- a/i18n/messages_zh.properties +++ b/i18n/messages_zh.properties @@ -902,6 +902,16 @@ core/nginx/not-running=Nginx 未运行 core/nginx/stats-fetch-failed=获取流量统计失败 core/nginx/stats-not-enabled=流量统计未启用 core/nginx/version-check-failed=检查 Nginx 版本失败 +core/notification/category/certificate-expiring=证书即将过期 +core/notification/category/certificate-renew-failed=证书续期失败 +core/notification/category/certificate-renewed=证书续期完成 +core/notification/category/nginx-reload-failed=Nginx 重载失败 +core/notification/category/nginx-reload-succeeded=Nginx 重载成功 +core/notification/category/unknown=未知的通知类别 +core/notification/configuration-not-found=未找到通知配置 +core/notification/duplicated-name=已存在同名的配置 +core/notification/invalid-category=通知类别无效 +core/notification/not-found=未找到通知 core/settings/invalid-extension=路径必须以 "${extension}" 结尾 core/settings/invalid-folder=路径必须指向现有文件夹 core/stream/at-least-one-backend=路由必须至少有一个后端 @@ -922,6 +932,7 @@ core/user/invalid-credentials=用户名或密码无效 core/user/invalid-totp-code=无效的 TOTP 代码 core/user/not-found-by-id=未找到提供的 ID 对应的用户 core/user/not-found=未找到用户 +core/user/notification-language=通知语言 core/user/password-reset-mode=应用程序已使用密码重置程序启动。请禁用它以继续。 core/user/too-short=至少应包含 ${min} 个字符 core/user/totp-not-enabled=此用户未启用 TOTP @@ -1566,6 +1577,35 @@ integration/truenas/proxy-url-resolution-failed=无法解析 ${id} 的代理 URL integration/truenas/proxy-url=应用 URL integration/truenas/url-help=您的 NAS 可访问的 URL,例如 http://192.168.0.2 或 https://nas.yourdomain.com integration/truenas/url=URL +notification/event/certificate-expiring/action-label=查看证书 +notification/event/certificate-expiring/section-body=证书 ${domain} 将于 ${expiresAt} 过期。 +notification/event/certificate-expiring/summary=证书 ${domain} 即将过期。 +notification/event/certificate-expiring/title=证书即将过期:${domain} +notification/event/certificate-renew-failed/action-label=查看证书 +notification/event/certificate-renew-failed/section-body=${domain} 的自动续期失败:${reason} +notification/event/certificate-renew-failed/summary=${domain} 续期失败 +notification/event/certificate-renew-failed/title=证书续期失败:${domain} +notification/event/certificate-renewed/action-label=查看证书 +notification/event/certificate-renewed/section-body=证书 ${domain} 已成功续期。 +notification/event/certificate-renewed/summary=证书 ${domain} 已续期 +notification/event/certificate-renewed/title=证书已续期:${domain} +notification/event/nginx-reload-failed/section-body=Nginx 重载失败:${reason} +notification/event/nginx-reload-failed/summary=Nginx 无法应用新配置 +notification/event/nginx-reload-failed/title=Nginx 重载未成功 +notification/event/nginx-reload-succeeded/summary=Nginx 已成功重载 +notification/event/nginx-reload-succeeded/title=Nginx 重载完成 +notification/smtp/from=发件人地址 +notification/smtp/host=SMTP 主机 +notification/smtp/instruction-app-password=对于 Gmail 及类似提供商,请使用应用专用密码,而非账户密码。 +notification/smtp/instruction-tls=在端口 587 上启用 STARTTLS,或在端口 465 上启用隐式 TLS(SMTPS)。请勿同时启用两种 TLS 模式。 +notification/smtp/name=SMTP +notification/smtp/password=密码 +notification/smtp/port=SMTP 端口 +notification/smtp/to-help=以逗号分隔的电子邮件地址 +notification/smtp/to=收件人 +notification/smtp/use-start-tls=使用 STARTTLS +notification/smtp/use-tls=使用 TLS(SMTPS) +notification/smtp/username=用户名 vpn/netbird/instruction-key-settings=生成密钥时,请确保选择“可重用 (Reusable)”类型,启用“临时对等节点 (Ephemeral Peers)”选项,并将其配置为无过期日期和无限制使用次数。否则,nginx ignition 将无法正确管理和注册网络中的虚拟设备。 vpn/netbird/instruction-setup-key=可以在 NetBird 仪表板的“Setup Keys”下生成设置密钥。 vpn/netbird/management-url-help=自定义管理服务器 URL。留空以使用默认值 (api.netbird.io)。 diff --git a/integration/docker/go.mod b/integration/docker/go.mod index 8cf3133f1..2421393a1 100644 --- a/integration/docker/go.mod +++ b/integration/docker/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/integration/docker -go 1.26.3 +go 1.26.4 require ( github.com/moby/moby/api v1.54.2 diff --git a/integration/truenas/go.mod b/integration/truenas/go.mod index c8a187c6e..7239797f5 100644 --- a/integration/truenas/go.mod +++ b/integration/truenas/go.mod @@ -1,6 +1,6 @@ module dillmann.com.br/nginx-ignition/integration/truenas -go 1.26.3 +go 1.26.4 require ( github.com/patrickmn/go-cache v2.1.0+incompatible diff --git a/notification/smtp/artifacts_test.go b/notification/smtp/artifacts_test.go new file mode 100644 index 000000000..3ac0b7d55 --- /dev/null +++ b/notification/smtp/artifacts_test.go @@ -0,0 +1,23 @@ +package smtp + +import ( + "context" +) + +type stubMailSender struct { + lastMessage []byte + lastRecipients []string + lastSettings mailSettings +} + +func (stub *stubMailSender) Send( + _ context.Context, + settings mailSettings, + recipients []string, + message []byte, +) error { + stub.lastSettings = settings + stub.lastRecipients = append([]string(nil), recipients...) + stub.lastMessage = append([]byte(nil), message...) + return nil +} diff --git a/notification/smtp/constants.go b/notification/smtp/constants.go new file mode 100644 index 000000000..3ca60d594 --- /dev/null +++ b/notification/smtp/constants.go @@ -0,0 +1,104 @@ +package smtp + +import ( + "context" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" + "dillmann.com.br/nginx-ignition/core/common/i18n" +) + +const providerID = "SMTP" + +const ( + hostFieldID = "host" + portFieldID = "port" + usernameFieldID = "username" + passwordFieldID = "password" + fromFieldID = "from" + toFieldID = "to" + useTLSFieldID = "useTls" + useStartTLSFieldID = "useStartTls" +) + +func configurationFields(ctx context.Context) []dynamicfields.DynamicField { + return []dynamicfields.DynamicField{ + { + ID: hostFieldID, + Priority: 0, + Description: i18n.M(ctx, i18n.K.NotificationSmtpHost), + Required: true, + Sensitive: false, + Type: dynamicfields.SingleLineTextType, + }, + { + ID: portFieldID, + Priority: 1, + Description: i18n.M(ctx, i18n.K.NotificationSmtpPort), + Required: true, + Sensitive: false, + Type: dynamicfields.SingleLineTextType, + DefaultValue: "587", + }, + { + ID: usernameFieldID, + Priority: 2, + Description: i18n.M(ctx, i18n.K.NotificationSmtpUsername), + Required: false, + Sensitive: false, + Type: dynamicfields.SingleLineTextType, + }, + { + ID: passwordFieldID, + Priority: 3, + Description: i18n.M(ctx, i18n.K.NotificationSmtpPassword), + Required: false, + Sensitive: true, + Type: dynamicfields.SingleLineTextType, + }, + { + ID: fromFieldID, + Priority: 4, + Description: i18n.M(ctx, i18n.K.NotificationSmtpFrom), + Required: true, + Sensitive: false, + Type: dynamicfields.EmailType, + }, + { + ID: toFieldID, + Priority: 5, + Description: i18n.M(ctx, i18n.K.NotificationSmtpTo), + Required: true, + Sensitive: false, + Type: dynamicfields.SingleLineTextType, + HelpText: i18n.M(ctx, i18n.K.NotificationSmtpToHelp), + }, + { + ID: useTLSFieldID, + Priority: 6, + Description: i18n.M(ctx, i18n.K.NotificationSmtpUseTls), + Required: false, + Sensitive: false, + Type: dynamicfields.BooleanType, + DefaultValue: false, + }, + { + ID: useStartTLSFieldID, + Priority: 7, + Description: i18n.M(ctx, i18n.K.NotificationSmtpUseStartTls), + Required: false, + Sensitive: false, + Type: dynamicfields.BooleanType, + DefaultValue: true, + Conditions: []dynamicfields.Condition{ + {ParentField: useTLSFieldID, Value: false}, + }, + }, + } +} + +func importantInstructions(ctx context.Context) []*i18n.Message { + return []*i18n.Message{ + i18n.M(ctx, i18n.K.NotificationSmtpInstructionAppPassword), + i18n.M(ctx, i18n.K.NotificationSmtpInstructionTls), + } +} diff --git a/notification/smtp/go.mod b/notification/smtp/go.mod new file mode 100644 index 000000000..93b2e12c3 --- /dev/null +++ b/notification/smtp/go.mod @@ -0,0 +1,17 @@ +module dillmann.com.br/nginx-ignition/notification/smtp + +go 1.26.4 + +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/notification/smtp/go.sum b/notification/smtp/go.sum new file mode 100644 index 000000000..b092586b1 --- /dev/null +++ b/notification/smtp/go.sum @@ -0,0 +1,9 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/notification/smtp/html.go b/notification/smtp/html.go new file mode 100644 index 000000000..de464114d --- /dev/null +++ b/notification/smtp/html.go @@ -0,0 +1,82 @@ +package smtp + +import ( + "bytes" + "strings" + "text/template" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +const htmlBodyTemplate = ` +

{{html .Summary}}

+{{- range .Sections}} +{{- if .Title}}

{{html .Title}}

{{end}} +

{{html .Body}}

+{{- end}} +{{- if .Actions}} + +{{- end}} +` + +var htmlBodyTmpl = template.Must( + template.New("htmlBody").Funcs(template.FuncMap{ + "html": htmlEscape, + }).Parse(htmlBodyTemplate), +) + +type htmlBodyData struct { + Summary string + Sections []htmlSectionData + Actions []htmlActionData +} + +type htmlSectionData struct { + Title *string + Body string +} + +type htmlActionData struct { + Label string + URL string +} + +func formatHTMLBody(deliverable notification.Deliverable) string { + data := htmlBodyData{ + Summary: deliverable.Summary, + Sections: make([]htmlSectionData, len(deliverable.Sections)), + Actions: make([]htmlActionData, len(deliverable.Actions)), + } + + for index, section := range deliverable.Sections { + data.Sections[index] = htmlSectionData{ + Title: section.Title, + Body: section.Body, + } + } + + for index, action := range deliverable.Actions { + data.Actions[index] = htmlActionData{ + Label: action.Label, + URL: action.URL, + } + } + + var buffer bytes.Buffer + _ = htmlBodyTmpl.Execute(&buffer, data) + return buffer.String() +} + +func htmlEscape(value string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + ) + return replacer.Replace(value) +} diff --git a/notification/smtp/installer.go b/notification/smtp/installer.go new file mode 100644 index 000000000..88ffc9a13 --- /dev/null +++ b/notification/smtp/installer.go @@ -0,0 +1,7 @@ +package smtp + +import "dillmann.com.br/nginx-ignition/core/common/container" + +func Install() error { + return container.Provide(newProvider) +} diff --git a/notification/smtp/provider.go b/notification/smtp/provider.go new file mode 100644 index 000000000..e1fc698de --- /dev/null +++ b/notification/smtp/provider.go @@ -0,0 +1,45 @@ +package smtp + +import ( + "context" + + "dillmann.com.br/nginx-ignition/core/common/dynamicfields" + "dillmann.com.br/nginx-ignition/core/common/i18n" + "dillmann.com.br/nginx-ignition/core/notification" +) + +type Provider struct { + sender mailSender +} + +func newProvider() *Provider { + return &Provider{sender: defaultMailSender{}} +} + +func (p *Provider) ID() string { + return providerID +} + +func (p *Provider) Name(ctx context.Context) *i18n.Message { + return i18n.M(ctx, i18n.K.NotificationSmtpName) +} + +func (p *Provider) ImportantInstructions(ctx context.Context) []*i18n.Message { + return importantInstructions(ctx) +} + +func (p *Provider) ConfigurationFields(ctx context.Context) []dynamicfields.DynamicField { + return configurationFields(ctx) +} + +func (p *Provider) Send( + ctx context.Context, + parameters map[string]any, + deliverable notification.Deliverable, +) error { + settings, recipients := parseMailSettings(parameters) + + message := buildMessage(deliverable, settings.fromAddress, recipients) + + return p.sender.Send(ctx, settings, recipients, message) +} diff --git a/notification/smtp/provider_test.go b/notification/smtp/provider_test.go new file mode 100644 index 000000000..30fdb1dcf --- /dev/null +++ b/notification/smtp/provider_test.go @@ -0,0 +1,73 @@ +package smtp + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func Test_provider(t *testing.T) { + t.Run("ID", func(t *testing.T) { + t.Run("returns SMTP", func(t *testing.T) { + provider := newProvider() + + assert.Equal(t, "SMTP", provider.ID()) + }) + }) + + t.Run("Name", func(t *testing.T) { + t.Run("returns localized name", func(t *testing.T) { + provider := newProvider() + + require.NotNil(t, provider.Name(t.Context())) + }) + }) + + t.Run("ConfigurationFields", func(t *testing.T) { + t.Run("returns all configuration fields", func(t *testing.T) { + provider := newProvider() + + require.Len(t, provider.ConfigurationFields(t.Context()), 8) + }) + }) + + t.Run("ImportantInstructions", func(t *testing.T) { + t.Run("returns setup instructions", func(t *testing.T) { + provider := newProvider() + + require.Len(t, provider.ImportantInstructions(t.Context()), 2) + }) + }) + + t.Run("Send", func(t *testing.T) { + t.Run("delegates to mail sender", func(t *testing.T) { + stub := &stubMailSender{} + provider := newProvider() + provider.sender = stub + + err := provider.Send(t.Context(), map[string]any{ + hostFieldID: "127.0.0.1", + portFieldID: "1025", + fromFieldID: "sender@example.com", + toFieldID: "recipient@example.com", + useTLSFieldID: false, + }, notification.Deliverable{ + Title: "Test notification", + Summary: "Summary text", + OccurredAt: time.Now(), + Category: notification.CategoryCertificateExpiring, + }) + + require.NoError(t, err) + assert.Equal(t, "127.0.0.1", stub.lastSettings.host) + assert.Equal(t, 1025, stub.lastSettings.port) + assert.Equal(t, []string{"recipient@example.com"}, stub.lastRecipients) + assert.True(t, strings.Contains(string(stub.lastMessage), "Test notification")) + }) + }) +} diff --git a/notification/smtp/sender.go b/notification/smtp/sender.go new file mode 100644 index 000000000..00502b8c8 --- /dev/null +++ b/notification/smtp/sender.go @@ -0,0 +1,242 @@ +package smtp + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "mime" + "mime/quotedprintable" + "net" + "net/smtp" + "strconv" + "strings" + "time" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +type mailSettings struct { + host string + username string + password string + fromAddress string + port int + useTLS bool + useStartTLS bool +} + +type mailSender interface { + Send( + ctx context.Context, + settings mailSettings, + recipients []string, + message []byte, + ) error +} + +type defaultMailSender struct{} + +func (defaultMailSender) Send( + _ context.Context, + settings mailSettings, + recipients []string, + message []byte, +) error { + address := net.JoinHostPort(settings.host, strconv.Itoa(settings.port)) + + if settings.useTLS { + return sendWithTLS(address, settings, recipients, message) + } + + return sendWithPlainOrStartTLS(address, settings, recipients, message) +} + +func sendWithTLS( + address string, + settings mailSettings, + recipients []string, + message []byte, +) error { + tlsConfig := &tls.Config{ServerName: settings.host} + connection, err := tls.Dial("tcp", address, tlsConfig) + if err != nil { + return err + } + defer connection.Close() + + client, err := smtp.NewClient(connection, settings.host) + if err != nil { + return err + } + defer client.Close() + + return deliver(client, settings, recipients, message) +} + +func sendWithPlainOrStartTLS( + address string, + settings mailSettings, + recipients []string, + message []byte, +) error { + client, err := smtp.Dial(address) + if err != nil { + return err + } + defer client.Close() + + if settings.useStartTLS { + tlsConfig := &tls.Config{ServerName: settings.host} + if err := client.StartTLS(tlsConfig); err != nil { + return err + } + } + + return deliver(client, settings, recipients, message) +} + +func deliver( + client *smtp.Client, + settings mailSettings, + recipients []string, + message []byte, +) error { + if settings.username != "" || settings.password != "" { + auth := smtp.PlainAuth("", settings.username, settings.password, settings.host) + if err := client.Auth(auth); err != nil { + return err + } + } + + if err := client.Mail(settings.fromAddress); err != nil { + return err + } + + for _, recipient := range recipients { + if err := client.Rcpt(recipient); err != nil { + return err + } + } + + writer, err := client.Data() + if err != nil { + return err + } + + if _, err := writer.Write(message); err != nil { + return err + } + + if err := writer.Close(); err != nil { + return err + } + + return client.Quit() +} + +func parseMailSettings(parameters map[string]any) (mailSettings, []string) { + host, _ := parameters[hostFieldID].(string) + portText, _ := parameters[portFieldID].(string) + port, _ := strconv.Atoi(strings.TrimSpace(portText)) + fromAddress, _ := parameters[fromFieldID].(string) + recipientsText, _ := parameters[toFieldID].(string) + useTLS, _ := parameters[useTLSFieldID].(bool) + useStartTLS, _ := parameters[useStartTLSFieldID].(bool) + + settings := mailSettings{ + host: strings.TrimSpace(host), + port: port, + fromAddress: strings.TrimSpace(fromAddress), + useTLS: useTLS, + useStartTLS: useStartTLS, + } + + if username, casted := parameters[usernameFieldID].(string); casted { + settings.username = strings.TrimSpace(username) + } + + if password, casted := parameters[passwordFieldID].(string); casted { + settings.password = password + } + + return settings, parseRecipients(recipientsText) +} + +func parseRecipients(recipientsText string) []string { + parts := strings.Split(recipientsText, ",") + recipients := make([]string, 0, len(parts)) + + for _, part := range parts { + address := strings.TrimSpace(part) + if address == "" { + continue + } + + recipients = append(recipients, address) + } + + return recipients +} + +func buildMessage( + deliverable notification.Deliverable, + fromAddress string, + recipients []string, +) []byte { + plainBody := formatPlainBody(deliverable) + htmlBody := formatHTMLBody(deliverable) + + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, "From: %s\r\n", fromAddress) + _, _ = fmt.Fprintf(&buffer, "To: %s\r\n", strings.Join(recipients, ", ")) + encodedTitle := mime.QEncoding.Encode("utf-8", deliverable.Title) + _, _ = fmt.Fprintf(&buffer, "Subject: %s\r\n", encodedTitle) + _, _ = buffer.WriteString("MIME-Version: 1.0\r\n") + + boundary := fmt.Sprintf("nginx-ignition-%d", time.Now().UnixNano()) + _, _ = fmt.Fprintf(&buffer, "Content-Type: multipart/alternative; boundary=%q\r\n", boundary) + _, _ = buffer.WriteString("\r\n") + + writePart(&buffer, boundary, "text/plain; charset=utf-8", plainBody) + writePart(&buffer, boundary, "text/html; charset=utf-8", htmlBody) + + _, _ = fmt.Fprintf(&buffer, "--%s--\r\n", boundary) + + return buffer.Bytes() +} + +func writePart(buffer *bytes.Buffer, boundary, contentType, body string) { + _, _ = fmt.Fprintf(buffer, "--%s\r\n", boundary) + _, _ = fmt.Fprintf(buffer, "Content-Type: %s\r\n", contentType) + _, _ = buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + + writer := quotedprintable.NewWriter(buffer) + _, _ = writer.Write([]byte(body)) + _ = writer.Close() + _, _ = buffer.WriteString("\r\n") +} + +func formatPlainBody(deliverable notification.Deliverable) string { + var builder strings.Builder + _, _ = builder.WriteString(deliverable.Summary) + _, _ = builder.WriteString("\n\n") + + for _, section := range deliverable.Sections { + if section.Title != nil { + _, _ = builder.WriteString(*section.Title) + _, _ = builder.WriteString("\n") + } + _, _ = builder.WriteString(section.Body) + _, _ = builder.WriteString("\n\n") + } + + for _, action := range deliverable.Actions { + _, _ = builder.WriteString(action.Label) + _, _ = builder.WriteString(": ") + _, _ = builder.WriteString(action.URL) + _, _ = builder.WriteString("\n") + } + + return strings.TrimSpace(builder.String()) +} diff --git a/notification/smtp/sender_test.go b/notification/smtp/sender_test.go new file mode 100644 index 000000000..ba69acd8c --- /dev/null +++ b/notification/smtp/sender_test.go @@ -0,0 +1,80 @@ +package smtp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "dillmann.com.br/nginx-ignition/core/notification" +) + +func Test_sender(t *testing.T) { + t.Run("parseRecipients", func(t *testing.T) { + t.Run("parses comma-separated addresses", func(t *testing.T) { + recipients := parseRecipients("one@example.com, two@example.com ") + assert.Equal(t, []string{"one@example.com", "two@example.com"}, recipients) + }) + + t.Run("skips empty parts", func(t *testing.T) { + recipients := parseRecipients("one@example.com, , two@example.com") + assert.Equal(t, []string{"one@example.com", "two@example.com"}, recipients) + }) + }) + + t.Run("parseMailSettings", func(t *testing.T) { + t.Run("parses saved parameters", func(t *testing.T) { + settings, recipients := parseMailSettings(map[string]any{ + hostFieldID: "smtp.example.com", + portFieldID: "587", + fromFieldID: "sender@example.com", + toFieldID: "recipient@example.com", + useStartTLSFieldID: true, + usernameFieldID: "user", + passwordFieldID: "secret", + }) + assert.Equal(t, "smtp.example.com", settings.host) + assert.Equal(t, 587, settings.port) + assert.Equal(t, "sender@example.com", settings.fromAddress) + assert.True(t, settings.useStartTLS) + assert.False(t, settings.useTLS) + assert.Equal(t, "user", settings.username) + assert.Equal(t, "secret", settings.password) + assert.Equal(t, []string{"recipient@example.com"}, recipients) + }) + }) + + t.Run("buildMessage", func(t *testing.T) { + t.Run("builds message with all deliverable sections", func(t *testing.T) { + message := buildMessage(notification.Deliverable{ + Title: "Certificate expiring", + Summary: "The certificate will expire soon.", + Sections: []notification.DeliverableContentSection{ + {Title: new("Details"), Body: "Renew before the deadline."}, + }, + Actions: []notification.DeliverableAction{ + {Label: "Open certificate", URL: "https://example.com/certificates/1"}, + }, + }, "alerts@example.com", []string{"recipient@example.com", "other@example.com"}) + + body := string(message) + assert.Contains(t, body, "From: alerts@example.com") + assert.Contains(t, body, "To: recipient@example.com, other@example.com") + assert.Contains(t, body, "Certificate expiring") + assert.Contains(t, body, "The certificate will expire soon.") + assert.Contains(t, body, "Renew before the deadline.") + assert.Contains(t, body, "Open certificate") + assert.Contains(t, body, "https://example.com/certificates/1") + }) + }) + + t.Run("formatHTMLBody", func(t *testing.T) { + t.Run("escapes special characters", func(t *testing.T) { + body := formatHTMLBody(notification.Deliverable{ + Summary: "Value