Compare commits
77
Commits
f0720ec66b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125a9e673e | ||
|
|
5a0b211d5f | ||
|
|
cd0981cea1 | ||
|
|
efeac88a49 | ||
|
|
3b551f1565 | ||
|
|
645181c65c | ||
|
|
de449d53bf | ||
|
|
fe60b7ac1a | ||
|
|
b644a10663 | ||
|
|
9370eacd9c | ||
|
|
5c255296d3 | ||
|
|
c106706615 | ||
|
|
915b8e8123 | ||
|
|
ec4115d083 | ||
|
|
f3ff7fc016 | ||
|
|
658d715c8a | ||
|
|
288a2f0c02 | ||
|
|
4605a197ad | ||
|
|
62df9eac5f | ||
|
|
4b22f6ce12 | ||
|
|
b5c4772168 | ||
|
|
464bd2d565 | ||
|
|
f1ff40d072 | ||
|
|
417e6d96b6 | ||
|
|
c8ef9c3d14 | ||
|
|
3af60f38b1 | ||
|
|
e84d2d1ad3 | ||
|
|
fa7b75d1b8 | ||
|
|
a587cbbc24 | ||
|
|
5f3b443e7b | ||
|
|
30f06c2f8f | ||
|
|
4c4b639321 | ||
|
|
13cc9c302e | ||
|
|
021bd04324 | ||
|
|
4844fb5bc9 | ||
|
|
3aeea9aa77 | ||
|
|
69fba8df70 | ||
|
|
b36eb72c23 | ||
|
|
3e4e496037 | ||
|
|
c1d28e705e | ||
|
|
abca15f047 | ||
|
|
c6ab4680fd | ||
|
|
86b6aafe7d | ||
|
|
bd675357bb | ||
|
|
24af794112 | ||
|
|
a5689876ff | ||
|
|
34d8ef18d1 | ||
|
|
7a6510d1a8 | ||
|
|
5d67c25c8e | ||
|
|
b303285b8b | ||
|
|
1354551ae8 | ||
|
|
7bf8b8087a | ||
|
|
a584c8189a | ||
|
|
30f0455122 | ||
|
|
9f9525df56 | ||
|
|
259f84e0a1 | ||
|
|
d04c8b12ab | ||
|
|
ce6583f454 | ||
|
|
caad7a3d5b | ||
|
|
1fe173397f | ||
|
|
9d57a65010 | ||
|
|
9dbf0bd80c | ||
|
|
1b8800ee73 | ||
|
|
23f7b78c3b | ||
|
|
80d6917aa7 | ||
|
|
184a3be413 | ||
|
|
8e2ef7aa88 | ||
|
|
fc33630a0a | ||
|
|
ecb63b61c6 | ||
|
|
12a38cd654 | ||
|
|
b6039493c9 | ||
|
|
1a079e00cc | ||
|
|
ad5a061164 | ||
|
|
4b14649b3a | ||
|
|
326d58f705 | ||
|
|
5c2d36281b | ||
|
|
36277b6c45 |
No files matched your search
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.local
|
||||
**/.dart_tool
|
||||
**/build
|
||||
**/.env*
|
||||
**/node_modules
|
||||
**/*.pb.go
|
||||
frontend/app/lib/generated
|
||||
**/.DS_Store
|
||||
dist
|
||||
website
|
||||
**/config.yml
|
||||
**/tls
|
||||
**/*.pem
|
||||
**/*.key
|
||||
**/__pycache__
|
||||
+11
@@ -29,3 +29,14 @@ pnpm-debug.log*
|
||||
# Local BFF configuration and build output
|
||||
/backend/config.yml
|
||||
/backend/bff
|
||||
|
||||
# Protobuf client bindings are generated, never committed.
|
||||
/backend/notifications/interface/notifications/**/*.pb.go
|
||||
/backend/notifications/config.yml
|
||||
/backend/notifications/notifications
|
||||
|
||||
# Local container pins, Vault bootstrap material and service identities
|
||||
/.local/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
@@ -8,6 +8,39 @@
|
||||
- For cross-module work, apply every relevant scoped instruction file and
|
||||
completion workflow.
|
||||
|
||||
## Autogenerated code
|
||||
|
||||
- Never commit autogenerated code to the repository. This applies to backend,
|
||||
frontend, shared packages, and every other module, including generated
|
||||
protobuf bindings, API clients, serializers, and framework-generated code.
|
||||
- Use externally published schemas as generation inputs. Do not embed or commit
|
||||
schema snapshots in the repository. Commit external schema references,
|
||||
generator configuration, and reproducible generation commands instead.
|
||||
- Fetch the externally published schemas and generate code at build time, before
|
||||
any dependent validation or compilation steps, including in CI.
|
||||
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
||||
files or copy generated code into tracked source files.
|
||||
- Change schema sources in their owning project or update external schema
|
||||
references and generator inputs rather than editing generated outputs.
|
||||
Document the generation workflow so a clean checkout can build without
|
||||
committed generated code or embedded schema snapshots.
|
||||
|
||||
## Local development tooling
|
||||
|
||||
Use Make and shell for local development orchestration. Do not introduce Python
|
||||
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
|
||||
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
|
||||
|
||||
## Container versions
|
||||
|
||||
Use only the latest stable upstream releases for infrastructure, product base
|
||||
images, build stages and disposable test containers. Exclude prereleases and
|
||||
vendor mainline channels when a separate stable channel exists. Resolve stable
|
||||
versions when building, pull fresh manifests, and pin the resolved image digests
|
||||
for that local build. Fail when stable release resolution fails; never silently
|
||||
fall back to an older cached version. Keep the reproducible resolver tracked and
|
||||
its generated local pins ignored by Git.
|
||||
|
||||
## Data and state architecture
|
||||
|
||||
Preserve explicit **DTO → mapper → service → data model → provider → UI**
|
||||
@@ -139,3 +172,18 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
|
||||
into task commits. Record scope, validation, limitations, and PR links in the
|
||||
task and durable repository documentation. Merge only when authorized by the
|
||||
user. If work is delivered as stacked PRs, document dependencies and merge order.
|
||||
|
||||
## Local infrastructure ownership
|
||||
|
||||
Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service
|
||||
configuration and shell tooling under `infra/local/`. Keep product source under
|
||||
`backend/` and `frontend/`. Organize Vault KV paths by owning service under
|
||||
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
|
||||
and bootstrap administration under `infrastructure/<component>/bootstrap`.
|
||||
Do not add BFF-to-notifications caller authentication on the trusted private network.
|
||||
Preserve NATS authentication.
|
||||
Use Caddy for local browser HTTPS and certificate management. Treat the private
|
||||
local Docker network as trusted; do not add internal gRPC TLS or Vault-managed
|
||||
local TLS certificates without an explicit requirement.
|
||||
Store only secret values in Vault. Driver selection, sender identities, endpoints
|
||||
and other non-secret runtime settings belong in service configuration files.
|
||||
@@ -5,6 +5,7 @@ test: test-backend test-frontend
|
||||
.PHONY: test-frontend
|
||||
|
||||
test-frontend:
|
||||
cd frontend/app && flutter gen-l10n
|
||||
cd frontend/packages/sxchnge_wallet && flutter test
|
||||
cd frontend/packages/sxchnge_kyc && flutter test
|
||||
cd frontend/packages/sxchnge_analytics && flutter test
|
||||
@@ -15,15 +16,86 @@ test-frontend:
|
||||
|
||||
.PHONY: test-backend lint-backend run-backend
|
||||
|
||||
test-backend:
|
||||
cd backend/shared && go test -race ./...
|
||||
cd backend/product && go test -race ./...
|
||||
test-backend: generate-notifications
|
||||
docker pull mongo:latest
|
||||
docker pull nats:latest
|
||||
cd backend/shared && go test -race -count=1 ./...
|
||||
cd backend/notifications/interface && go test -race -count=1 ./...
|
||||
cd backend/notifications && go test -race -count=1 ./...
|
||||
cd backend/product && go test -race -count=1 ./...
|
||||
|
||||
lint-backend:
|
||||
lint-backend: generate-notifications
|
||||
cd backend/product && go test ./architecture
|
||||
cd backend && test -z "$$(gofmt -l .)"
|
||||
cd backend/shared && go vet ./...
|
||||
cd backend/notifications/interface && go vet ./...
|
||||
cd backend/notifications && go vet ./...
|
||||
cd backend/product && go vet ./...
|
||||
|
||||
run-backend:
|
||||
cd backend/product && go run .
|
||||
|
||||
.PHONY: run-notifications generate-notifications
|
||||
run-notifications: generate-notifications
|
||||
cd backend/notifications && go run .
|
||||
|
||||
generate-notifications:
|
||||
@command -v protoc >/dev/null
|
||||
@tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \
|
||||
GOBIN="$$tmp" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 && \
|
||||
GOBIN="$$tmp" go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 && \
|
||||
PATH="$$tmp:$$PATH" protoc -I backend/notifications/interface/proto --go_out=backend/notifications/interface --go_opt=module=git.sendico.io/tech/sxchnge/notifications/interface --go-grpc_out=backend/notifications/interface --go-grpc_opt=module=git.sendico.io/tech/sxchnge/notifications/interface backend/notifications/interface/proto/v1/interface.proto
|
||||
|
||||
.PHONY: package-notifications-interface
|
||||
package-notifications-interface: generate-notifications
|
||||
mkdir -p dist
|
||||
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
|
||||
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
|
||||
|
||||
# Local product stack. Releases are resolved afresh, then pinned by digest.
|
||||
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
|
||||
ifneq ($(wildcard .local/dev/sendico/settings.env),)
|
||||
COMPOSE += -f infra/local/sendico/compose.yml
|
||||
endif
|
||||
.PHONY: build up down logs ps test-dev
|
||||
build:
|
||||
sh infra/local/tools.sh build
|
||||
sh infra/local/tools.sh images
|
||||
$(COMPOSE) pull vault mongo
|
||||
$(COMPOSE) build --pull
|
||||
|
||||
up:
|
||||
sh infra/local/tools.sh up
|
||||
|
||||
down:
|
||||
$(COMPOSE) down
|
||||
|
||||
logs:
|
||||
$(COMPOSE) logs --tail=100 -f
|
||||
|
||||
ps:
|
||||
$(COMPOSE) ps
|
||||
|
||||
test-dev:
|
||||
sh infra/local/tools.sh test
|
||||
sh infra/local/tools.sh verify
|
||||
|
||||
.PHONY: test-dev-infra
|
||||
test-dev-infra:
|
||||
sh infra/local/tools.sh test
|
||||
sh infra/local/tools.sh verify-infra
|
||||
|
||||
.PHONY: test-dev-rotation
|
||||
test-dev-rotation:
|
||||
sh infra/local/tools.sh rotation
|
||||
|
||||
.PHONY: email test-dev-tooling
|
||||
email:
|
||||
sh infra/local/tools.sh email
|
||||
|
||||
test-dev-tooling:
|
||||
sh infra/local/tools.sh test
|
||||
|
||||
.PHONY: sendico
|
||||
sendico:
|
||||
sh infra/local/tools.sh sendico
|
||||
@@ -9,11 +9,11 @@ The consumer app includes responsive login/signup, English and Russian
|
||||
localization, email verification and recovery screens, and URL-based navigation
|
||||
with `go_router`. Its interface uses the shared theme's colors and typography.
|
||||
|
||||
**Authentication, email verification and analytics use dummy services.** No real
|
||||
account or session is created, no email is delivered, and no analytics data is
|
||||
transmitted by those app services. The backend now contains locally owned copies
|
||||
of Sendico’s BFF runtime, auth/session stack and account service. The app is not
|
||||
connected to that backend or to Sendico yet.
|
||||
The default preview uses dummy services. Configuring the existing backend URL
|
||||
and client ID selects authenticated BFF adapters for auth, identity, limits and
|
||||
wallet quotations/payment execution. Optional Sendico configuration and dedicated
|
||||
customer financial resources are required for real operations; analytics still
|
||||
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
|
||||
|
||||
## Repository structure
|
||||
|
||||
@@ -32,6 +32,24 @@ Each application owns its dependencies and build configuration. Shared Dart and
|
||||
Flutter packages can be reused by a future mobile app; mobile runners are not
|
||||
currently included. Local `.vscode/` configuration is ignored.
|
||||
|
||||
## Local Docker development
|
||||
|
||||
```sh
|
||||
make build && make up
|
||||
```
|
||||
|
||||
This builds the product images and starts an isolated Vault, authenticated MongoDB
|
||||
replica set and NATS JetStream. First startup intentionally stops after bootstrap
|
||||
until you set `api_key` in Vault's `kv/services/notifications/email/resend` secret
|
||||
and the verified sender in `infra/local/notifications/runtime.yml`.
|
||||
Then rerun `make up` to start notifications, BFF and the frontend at
|
||||
[https://localhost:5126](https://localhost:5126).
|
||||
|
||||
All image inputs resolve to current stable releases and are pinned by digest for
|
||||
the build. Services fetch secrets from Vault; Mongo application credentials come
|
||||
from Vault's database plugin. See the [local development guide](docs/local-development.md)
|
||||
for Vault provisioning, local HTTPS trust, lifecycle commands and verification.
|
||||
|
||||
## Run the consumer app
|
||||
|
||||
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
|
||||
@@ -54,7 +72,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
|
||||
repository root:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web
|
||||
docker run --rm --pull always -p 127.0.0.1:5123:80 \
|
||||
-v "$PWD/frontend/app/build/web:/srv:ro" caddy:alpine caddy file-server --root /srv --listen :80
|
||||
```
|
||||
|
||||
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
|
||||
@@ -237,7 +256,9 @@ gaps and delivery phases. Selected Sendico code has been adapted with recorded
|
||||
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
|
||||
|
||||
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
|
||||
The copied runtime installs local authentication transport and initializes its
|
||||
account service. Signup/profile endpoints, verification delivery/completion,
|
||||
Flutter HTTP adapters and live Sendico operations remain to be wired. See the
|
||||
[backend run guide](backend/README.md) for local MongoDB configuration.
|
||||
The runtime installs local authentication, verification delivery, identity and
|
||||
wallet transports. Public Sendico adapters support independent customer facts,
|
||||
reviewed identity/limits and quotation/execution through the BFF. Deployment and
|
||||
financial-resource provisioning are separate; backend account-language saving
|
||||
and additional funding/recipient onboarding remain follow-up work. See the
|
||||
[backend run guide](backend/README.md) and [payment integration](docs/payments.md).
|
||||
@@ -22,6 +22,28 @@ skill to perform this completion workflow.
|
||||
For example, prefer a concise path such as `payment.Details.ID` over
|
||||
`payment.Payment.PaymentID`.
|
||||
|
||||
## Reuse shared database and messaging infrastructure
|
||||
|
||||
- All database integrations must use the existing shared database wrappers in
|
||||
`backend/shared/database`. Do not bypass them with direct database clients or
|
||||
create feature-local replacements.
|
||||
- All NATS connections must use shared messaging drivers owned by the
|
||||
`backend/shared` library module. Keep reusable NATS drivers in
|
||||
`backend/shared/messaging`; executable modules must consume that package.
|
||||
Do not open direct NATS connections or implement separate connection
|
||||
management in a feature or service.
|
||||
- If the shared messaging package is missing, consolidate the existing driver
|
||||
into `backend/shared/messaging` and reuse it. Do not create another
|
||||
feature-local driver or import a sibling executable module's infrastructure.
|
||||
- Search for the existing wrappers, drivers, and their usage before implementing
|
||||
an integration. Reuse their connection lifecycle, configuration, retries, and
|
||||
other infrastructure behavior instead of copying or rewriting it.
|
||||
- If an integration needs a capability the shared infrastructure does not yet
|
||||
expose, extend the canonical wrapper or driver. Keep reusable infrastructure
|
||||
there and domain-specific behavior in the owning module.
|
||||
- During review, check that database and NATS integrations use these shared
|
||||
paths and remove any duplicated infrastructure introduced by the task.
|
||||
|
||||
## Use the project error model
|
||||
|
||||
- Do not introduce `fmt.Errorf` or `errors.New`. Use the constructors and
|
||||
|
||||
@@ -71,3 +71,11 @@ code. Auth and verification configurations moved out of application interfaces.
|
||||
All 112 unconsumed public API snapshot files and their snapshot-only check were
|
||||
removed. Future public adapters will own just the wire types they consume.
|
||||
Backoffice is a separate future executable module, not an alias for product.
|
||||
|
||||
## Second-factor separation (#40)
|
||||
|
||||
The new `product/twofactor` domain service reuses the already copied
|
||||
`product/verification/store` issuance and consumption implementation at the pinned
|
||||
Sendico revision. No additional Sendico source files were copied. Coupled
|
||||
verification HTTP/session/private-messaging handlers were inspected but excluded.
|
||||
See [reuse decisions and domain boundary](../docs/architecture/two-factor.md).
|
||||
+6
-2
@@ -12,6 +12,8 @@ backend/
|
||||
│ ├── auth/ Login/protected HTTP routes, DTOs and configuration
|
||||
│ ├── sessions/ Session models, refresh storage, cookies and tokens
|
||||
│ └── verification/ Verification models, configuration and storage
|
||||
├── notifications/ Internal gRPC notification service and email workers
|
||||
│ └── interface/ Independent protobuf/client contract module
|
||||
└── shared/ Reusable library module
|
||||
├── go.mod
|
||||
├── database/ Connection, transactions and repository primitives
|
||||
@@ -27,9 +29,11 @@ not implemented by this customer-BFF bootstrap. Shared infrastructure has no
|
||||
local `replace` for `../shared`; each module has its own dependency manifest.
|
||||
|
||||
Run the product with `make run-backend`; see its [run guide](product/README.md).
|
||||
`make test` runs both backend modules and the frontend. `make lint-backend` checks
|
||||
both Go modules and their architecture boundaries.
|
||||
`make test` runs all backend modules and the frontend. `make lint-backend` checks
|
||||
all Go modules and their architecture boundaries.
|
||||
|
||||
Copied source remains locally owned. See [provenance](PROVENANCE.md), the
|
||||
[architecture/API plan](../docs/architecture/bff.md) and the
|
||||
[delivery record](../docs/implementation.md).
|
||||
|
||||
Notifications uses shared database and messaging infrastructure. See its [run guide](notifications/README.md) and [protobuf interface](notifications/interface/proto/v1/interface.proto). Generated client bindings are ignored; Make generates them before backend checks.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Source provenance
|
||||
|
||||
SendGrid driver source adapted from the local Sendico checkout at commit
|
||||
`8bb20dcd622dd8eb92d289b6b34411e7eccab083`:
|
||||
`api/notification/internal/server/notificationimp/mail/internal/sendgrid.go`.
|
||||
|
||||
The adaptation retains SendGrid SDK message construction, sender configuration
|
||||
and personalization. It implements our provider-neutral `email.Sender` contract,
|
||||
accepts already-rendered subject/text/HTML, uses bounded context-aware HTTP,
|
||||
classifies delivery outcomes and never logs provider response bodies. Sendico's
|
||||
builders, dynamic-template dependency, notification events and unused producer
|
||||
were removed. Resend is a new driver using the same contract.
|
||||
|
||||
SMTP is an owned adapter using Go's standard `net/smtp`, verified `crypto/tls`
|
||||
connections and standard MIME encoders. Its implicit-TLS connection configuration
|
||||
matches Sendico's existing mail client, without importing its modules or copying
|
||||
its keepalive/reconnect behavior. Local mailbox credentials are provisioned into
|
||||
SXCHNGE Vault explicitly; the application does not read Sendico files or secrets.
|
||||
|
||||
Shared messaging was selected from that same Sendico revision's
|
||||
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
||||
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
||||
`internal/natsb/broker.go`, `internal/natsb/config/config.go`, and
|
||||
`internal/natscap/durable.go`. The selected code lives in
|
||||
`backend/shared/messaging`, with imports adapted to SXCHNGE. The runtime composes
|
||||
those adapters and owns connection closure. Unused discovery, best-effort envelope
|
||||
subscriptions, platform stream registries and parking provisioning were omitted.
|
||||
The durable adapter was separated into transport, subscription and admin files;
|
||||
publishing now honors its context and fetch polling respects its bounded wait.
|
||||
|
||||
No Sendico module or live Sendico infrastructure is required. Use SXCHNGE-owned
|
||||
MongoDB and NATS deployments.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Notifications backend
|
||||
|
||||
Independent internal gRPC service with durable asynchronous email delivery.
|
||||
The public contract belongs to [interface/proto/v1/interface.proto](interface/proto/v1/interface.proto).
|
||||
Generated clients are build artifacts, not committed source.
|
||||
|
||||
## Architecture
|
||||
|
||||
The gRPC transport maps protobuf DTOs to domain requests. The service validates
|
||||
and renders immutable template revisions, then saves a notification and its
|
||||
pending-work state atomically in one document through the shared DB wrapper.
|
||||
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
||||
transport, adapted from Sendico. A durable NATS worker claims the notification
|
||||
with an atomic conditional patch and saves the outcome before acknowledging.
|
||||
`DriverConfig` selects the email driver: `sendgrid`, `resend` or `smtp`.
|
||||
|
||||
Email adapters only map rendered email models to provider wire payloads. SendGrid
|
||||
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
|
||||
subject, text and HTML, bounded timeouts and safe error classification.
|
||||
|
||||
The owned SMTP adapter uses authenticated implicit TLS (normally port 465),
|
||||
verified server certificates and context-bounded connections. It sends MIME
|
||||
multipart/alternative with quoted-printable UTF-8 text/HTML and encoded subjects.
|
||||
It uses Go's standard SMTP client and does not import Sendico modules or share a
|
||||
mutable connection between workers. Configure it with:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
driver: smtp
|
||||
settings:
|
||||
host: mail.example.com
|
||||
port: 465
|
||||
username: notifications@example.com
|
||||
password_env: SMTP_PASSWORD
|
||||
sender_address: notifications@example.com
|
||||
sender_name: SXCHNGE
|
||||
network_timeout: 10
|
||||
```
|
||||
|
||||
`network_timeout` must be 1–20 seconds; the worker's expiry/shutdown context also
|
||||
bounds every operation. Only the password belongs in Vault. There is no plaintext
|
||||
fallback or certificate-verification bypass.
|
||||
|
||||
## Run
|
||||
|
||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
||||
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
|
||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
|
||||
variable. Configure a verified sender address and template
|
||||
catalog. Paths are relative to the process working directory.
|
||||
|
||||
From the root, run `make run-notifications`. The gRPC listener uses plaintext on the private local network. Configure both
|
||||
`tls_cert` and `tls_key` only when TLS is explicitly required.
|
||||
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL
|
||||
are supported by the shared driver. Mongo and messaging both use shared
|
||||
`DriverConfig` settings; email settings reject unknown keys.
|
||||
|
||||
The queue uses `SXCHNGE_NOTIFICATIONS`, subject `sxchnge.notifications.email`,
|
||||
and durable consumer `email`. Configure `queue.stream.replicas` as 1, 3 or 5 to
|
||||
match the NATS cluster, and `queue.stream.max_age_hours` for retention (48 hours
|
||||
minimum). File storage and the owned stream/subject names are enforced.
|
||||
`queue.provisioning.environment` accepts `production` or `non-production`;
|
||||
`mode: ops` validates existing resources without changing them, `mode: app`
|
||||
provisions missing resources, and `mode: hybrid` provisions only outside production.
|
||||
Production retention and consumer-setting drift fail startup instead of being
|
||||
silently accepted. Provisioning operations honor their startup context.
|
||||
The example defaults to one replica and app provisioning for local development.
|
||||
Multiple service workers can safely compete for jobs through atomic claims.
|
||||
|
||||
## Templates and parameters
|
||||
|
||||
See `templates.example.json`, including the `auth.activation` and `auth.login`
|
||||
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
|
||||
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
|
||||
locale-specific content. Missing/extra parameters, unknown revisions/locales and
|
||||
invalid content are rejected before acceptance. Empty locale uses the default.
|
||||
Values are data, not executable templates; HTML is escaped by `html/template`.
|
||||
The catalog is loaded and compiled at startup. Never edit a published revision;
|
||||
add a new version. Rendered payloads are persisted so retries do not depend on a
|
||||
later catalog change. Recipient, body and subject are cleared on terminal state;
|
||||
remaining status and request fingerprints expire through a seven-day TTL.
|
||||
|
||||
## Delivery semantics
|
||||
|
||||
- Send persists before returning; it does not wait for NATS or the email provider.
|
||||
- Outbox scans every five seconds and republishes safely after a bus failure.
|
||||
Pending notifications expire and have their rendered content cleared even when
|
||||
the bus is unavailable or a retry is scheduled later.
|
||||
- Duplicate RPCs and duplicate broker deliveries do not create extra jobs/sends.
|
||||
- Provider HTTP 2xx becomes ACCEPTED. No receipt/webhook or read tracking is claimed.
|
||||
- Explicit rate limiting (429) retries with bounded exponential backoff and
|
||||
Retry-After, at most five provider attempts, bounded by message expiry.
|
||||
- Transport errors, 408, 5xx and expired worker leases become UNKNOWN. These may
|
||||
have sent the email; operators must reconcile before requesting another send.
|
||||
- Other provider failures are terminal. `provider_authentication_failed` signals
|
||||
credentials/permissions, `provider_rejected` a nonretryable rejection,
|
||||
`retry_exhausted` the attempt limit, and `expired` the message deadline.
|
||||
- Resend receives the notification ID as its provider idempotency key. Its window
|
||||
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
||||
delivery is not promised.
|
||||
- SMTP acceptance requires the final DATA 250 reply. A later QUIT failure cannot
|
||||
undo acceptance. Explicit SMTP 4xx replies become retryable
|
||||
`provider_temporarily_unavailable`; 5xx replies are terminal (authentication
|
||||
failures retain `provider_authentication_failed`). Transport/TLS errors and a
|
||||
lost final DATA reply become UNKNOWN. No immediate reconnect/resend occurs.
|
||||
A stable Message-ID is included for correlation, not an exactly-once promise.
|
||||
- Changing the configured driver fails queued jobs pinned to the old driver with
|
||||
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
||||
- Callers authorize recipients and preferences. No direct customer-database access,
|
||||
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
|
||||
|
||||
Structured logs report notification IDs, driver, state and attempt count. Provider
|
||||
HTTP outcomes include `provider_status`; failures also include `provider_error`
|
||||
and `provider_message` when available. The adapter reads at most 16 KiB plus an
|
||||
overflow byte and extracts only the Resend `name`/`message` or first SendGrid
|
||||
`errors[].message`. Diagnostic messages are bounded to 512 Unicode characters
|
||||
plus an ellipsis. Credentials, idempotency keys, outgoing request string values,
|
||||
email addresses, URLs, long token-like strings and numeric verification/account
|
||||
values embedded in the request are redacted; controls and formatting characters
|
||||
are normalized. Malformed, incomplete and oversized error
|
||||
responses produce a diagnostic fallback instead of raw content. Successful
|
||||
response bodies are not logged. Diagnostic fields are transient: they are neither
|
||||
persisted in notifications nor exposed in gRPC responses. The existing bounded
|
||||
failure codes and delivery/retry decisions remain unchanged. Transport failures
|
||||
include a sanitized cause. Provider outcome logging happens before the result
|
||||
write so a storage failure cannot discard the only provider diagnostic.
|
||||
|
||||
Message content, template values, addresses and raw provider bodies must remain
|
||||
excluded from logs. Provider text is sanitized external diagnostic data, never
|
||||
an instruction. Use `provider_status`, `provider_error` and `provider_message`
|
||||
together: the generic `provider_authentication_failed` category combines HTTP
|
||||
401 and 403, including sender/domain authorization failures. A sending-only
|
||||
Resend key is valid for sending, but cannot read the provider's log API.
|
||||
SMTP outcomes use `provider_smtp_status` for protocol reply codes and the same
|
||||
sanitized provider error/message fields, including password/auth-payload redaction.
|
||||
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
|
||||
|
||||
## Validate and distribute
|
||||
|
||||
`make test` and `make lint-backend` generate interfaces and validate all modules.
|
||||
Notifications tests exercise real disposable MongoDB and NATS containers, concurrent
|
||||
idempotency and claims, lease recovery, retries, transport authentication, rendering
|
||||
and both provider payloads through local HTTP servers. An authenticated TLS gRPC
|
||||
Send/Get test covers the full shared-database → NATS → rendered-email driver path.
|
||||
No live emails are sent.
|
||||
`make package-notifications-interface` prepares source and Go-client archives.
|
||||
See [provenance](PROVENANCE.md) and [contract usage](interface/README.md).
|
||||
|
||||
Provider references: [SendGrid Mail Send](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send),
|
||||
[Resend Send Email](https://resend.com/docs/api-reference/emails/send-email).
|
||||
@@ -0,0 +1,51 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/bus"
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Queue bus.Config `yaml:"queue"`
|
||||
Messaging messaging.Config `yaml:"messaging"`
|
||||
Listen string `yaml:"listen"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
Database db.Config `yaml:"database"`
|
||||
Templates string `yaml:"templates"`
|
||||
Email email.Config `yaml:"email"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
c := Config{Queue: bus.DefaultConfig()}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return c, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot read configuration")
|
||||
}
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
if err = decoder.Decode(&c); err != nil {
|
||||
return c, merrors.InvalidArgument("invalid configuration")
|
||||
}
|
||||
if err = decoder.Decode(new(any)); err != io.EOF {
|
||||
return c, merrors.InvalidArgument("unexpected configuration content")
|
||||
}
|
||||
if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
|
||||
return c, merrors.InvalidArgument("missing notification configuration")
|
||||
}
|
||||
if (c.TLSCert == "") != (c.TLSKey == "") {
|
||||
return c, merrors.InvalidArgument("both TLS certificate and key must be configured together")
|
||||
}
|
||||
if err = c.Queue.Validate(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExampleConfigAndSecrets(t *testing.T) {
|
||||
c, err := Load("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
|
||||
t.Fatal("wrong driver configuration")
|
||||
}
|
||||
data, err := os.ReadFile("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err = os.WriteFile(path, append(data, []byte("\n---\nunexpected: document\n")...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = Load(path); err == nil {
|
||||
t.Fatal("extra YAML document accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
|
||||
c, err := Load("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := serverOptions(c)
|
||||
if err != nil || len(options) != 0 {
|
||||
t.Fatal("plain gRPC must not require TLS material")
|
||||
}
|
||||
data, err := os.ReadFile("../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, partial := range []string{"\ntls_cert: missing.crt\n", "\ntls_key: missing.key\n"} {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, append(data, []byte(partial)...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("incomplete TLS configuration accepted")
|
||||
}
|
||||
}
|
||||
if _, err := serverOptions(Config{TLSCert: "missing.crt", TLSKey: "missing.key"}); err == nil {
|
||||
t.Fatal("bad explicit certificate accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/bus"
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/service"
|
||||
"git.sendico.io/tech/sxchnge/notifications/store"
|
||||
"git.sendico.io/tech/sxchnge/notifications/templates"
|
||||
"git.sendico.io/tech/sxchnge/notifications/transport"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
catalog, err := templates.Load(c.Templates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sender, err := email.New(c.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options, err := serverOptions(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := db.ConnectMongo(logger, &c.Database)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "connect notifications database")
|
||||
}
|
||||
defer func() {
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = client.Disconnect(cleanup)
|
||||
}()
|
||||
repo, err := store.New(logger, client.Database())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := msgruntime.New(logger, &c.Messaging)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rt.Close()
|
||||
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
|
||||
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer broker.Close()
|
||||
server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
|
||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||
listener, err := net.Listen("tcp", c.Listen)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "listen for notifications")
|
||||
}
|
||||
defer listener.Close()
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
defer stop()
|
||||
var wg sync.WaitGroup
|
||||
for _, run := range []func(context.Context){broker.RunPublisher, broker.RunWorker} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); run(runCtx) }()
|
||||
}
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- server.Serve(listener) }()
|
||||
logger.Info("Notifications service started", zap.String("listen", c.Listen), zap.String("email_driver", string(c.Email.Driver)))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err = <-serveErr:
|
||||
}
|
||||
stop()
|
||||
stopped := make(chan struct{})
|
||||
go func() { server.GracefulStop(); close(stopped) }()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(5 * time.Second):
|
||||
server.Stop()
|
||||
}
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrInternal, err, "notification server stopped unexpectedly")
|
||||
}
|
||||
logger.Info("Notifications service stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// An omitted certificate pair uses plaintext gRPC on the private service network.
|
||||
func serverOptions(c Config) ([]grpc.ServerOption, error) {
|
||||
if c.TLSCert == "" && c.TLSKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
||||
}
|
||||
return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))}, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
)
|
||||
|
||||
// Config composes canonical shared messaging settings for this service's queue.
|
||||
type Config struct {
|
||||
Stream reliable.DurableStreamSettings `yaml:"stream"`
|
||||
Provisioning reliable.DurableProvisioningSettings `yaml:"provisioning"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Stream: reliable.DurableStreamSettings{Name: "SXCHNGE_NOTIFICATIONS", Subjects: []string{subject}, Storage: "file", MaxAgeHours: 48, Replicas: 1},
|
||||
Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningApp, Environment: reliable.DurableEnvironmentNonProduction},
|
||||
}
|
||||
}
|
||||
func (c Config) Validate() error {
|
||||
if c.Stream.Name != "SXCHNGE_NOTIFICATIONS" || len(c.Stream.Subjects) != 1 || c.Stream.Subjects[0] != subject || c.Stream.Storage != "file" || c.Stream.MaxAgeHours < 48 || c.Stream.MaxAgeHours > 24*30 {
|
||||
return merrors.InvalidArgument("invalid notifications stream configuration")
|
||||
}
|
||||
if c.Stream.Replicas != 1 && c.Stream.Replicas != 3 && c.Stream.Replicas != 5 {
|
||||
return merrors.InvalidArgument("stream replicas must be 1, 3 or 5")
|
||||
}
|
||||
switch c.Provisioning.Mode {
|
||||
case reliable.DurableProvisioningApp, reliable.DurableProvisioningOps, reliable.DurableProvisioningHybrid:
|
||||
default:
|
||||
return merrors.InvalidArgument("invalid stream provisioning mode")
|
||||
}
|
||||
switch c.Provisioning.Environment {
|
||||
case reliable.DurableEnvironmentProduction, reliable.DurableEnvironmentNonProduction:
|
||||
default:
|
||||
return merrors.InvalidArgument("invalid stream provisioning environment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueueConfiguration(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Stream.Replicas = 3
|
||||
config.Provisioning.Mode = reliable.DurableProvisioningOps
|
||||
config.Provisioning.Environment = reliable.DurableEnvironmentProduction
|
||||
if err := config.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, change := range []func(*Config){func(c *Config) { c.Stream.Replicas = 0 }, func(c *Config) { c.Stream.Storage = "memory" }, func(c *Config) { c.Stream.MaxAgeHours = 1 }, func(c *Config) { c.Provisioning.Mode = "typo" }, func(c *Config) { c.Provisioning.Environment = "typo" }, func(c *Config) { c.Stream.Subjects = []string{"other"} }} {
|
||||
c := DefaultConfig()
|
||||
change(&c)
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Fatal("invalid queue configuration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package bus connects the notification outbox to the shared messaging runtime.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
)
|
||||
|
||||
const subject = "sxchnge.notifications.email"
|
||||
|
||||
type Outbox interface {
|
||||
Due(context.Context, time.Time) ([]model.Notification, error)
|
||||
Recover(context.Context, time.Time) error
|
||||
}
|
||||
type Handler interface {
|
||||
Dispatch(context.Context, string) (bool, error)
|
||||
}
|
||||
type Bus struct {
|
||||
transport reliable.Transport
|
||||
subscription reliable.Subscription
|
||||
outbox Outbox
|
||||
handler Handler
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin, outbox Outbox, handler Handler, logger mlogger.Logger, config Config) (*Bus, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
|
||||
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
|
||||
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
if err := admin.EnsureConsumerResources(startup, settings, consumer); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
|
||||
}
|
||||
// NATS retains this context and unsubscribes when it ends. The worker must
|
||||
// live until service shutdown, independently of the provisioning deadline.
|
||||
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
|
||||
}
|
||||
return &Bus{transport, subscription, outbox, handler, mlogger.Named(logger, "notifications", "bus")}, nil
|
||||
}
|
||||
func (b *Bus) Close() error { return b.subscription.Close() }
|
||||
func (b *Bus) PublishDue(ctx context.Context) error {
|
||||
if err := b.outbox.Recover(ctx, time.Now().UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := b.outbox.Due(ctx, time.Now().UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
err = b.transport.Publish(ctx, reliable.Record{Subject: subject, Payload: []byte(job.ID.Hex()), MessageID: job.ID.Hex() + "-" + strconv.FormatUint(uint64(job.Attempts), 10)})
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "publish notification job")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (b *Bus) RunPublisher(ctx context.Context) {
|
||||
for ctx.Err() == nil {
|
||||
op, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := b.PublishDue(op)
|
||||
cancel()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
b.logger.Warn("Notification outbox publication failed")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
func (b *Bus) RunWorker(ctx context.Context) {
|
||||
for ctx.Err() == nil {
|
||||
deliveries, err := b.subscription.Fetch(ctx, 1, time.Second)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
b.logger.Warn("Notification job fetch failed")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, delivery := range deliveries {
|
||||
payload := delivery.Record().Payload
|
||||
if len(payload) == 24 {
|
||||
if _, err = b.handler.Dispatch(ctx, string(payload)); err != nil {
|
||||
b.logger.Warn("Notification job processing failed")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
b.logger.Warn("Malformed notification job discarded")
|
||||
}
|
||||
ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
err = delivery.Ack(ackCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.logger.Warn("Notification acknowledgement failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type outbox struct{ jobs []model.Notification }
|
||||
|
||||
func (o *outbox) Due(context.Context, time.Time) ([]model.Notification, error) { return o.jobs, nil }
|
||||
func (o *outbox) Recover(context.Context, time.Time) error { return nil }
|
||||
|
||||
type publisher struct {
|
||||
reliable.Transport
|
||||
fail bool
|
||||
records []reliable.Record
|
||||
}
|
||||
|
||||
type consumerTransport struct {
|
||||
reliable.Transport
|
||||
reliable.Subscription
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (t *consumerTransport) OpenConsumer(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) (reliable.Subscription, error) {
|
||||
t.ctx = ctx
|
||||
return t.Subscription, nil
|
||||
}
|
||||
|
||||
type consumerAdmin struct {
|
||||
reliable.Admin
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (a *consumerAdmin) EnsureConsumerResources(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) error {
|
||||
a.ctx = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubscriptionOutlivesProvisioning(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
transport := &consumerTransport{}
|
||||
admin := &consumerAdmin{}
|
||||
if _, err := New(ctx, transport, admin, &outbox{}, nil, zap.NewNop(), DefaultConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if admin.ctx.Err() != context.Canceled {
|
||||
t.Fatal("provisioning context was not released")
|
||||
}
|
||||
if _, ok := admin.ctx.Deadline(); !ok {
|
||||
t.Fatal("provisioning has no deadline")
|
||||
}
|
||||
if err := transport.ctx.Err(); err != nil {
|
||||
t.Fatalf("subscription ended with provisioning: %v", err)
|
||||
}
|
||||
cancel()
|
||||
if transport.ctx.Err() != context.Canceled {
|
||||
t.Fatal("subscription does not follow service shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
|
||||
if p.fail {
|
||||
return merrors.ServiceUnavailable("offline")
|
||||
}
|
||||
p.records = append(p.records, r)
|
||||
return nil
|
||||
}
|
||||
func TestOutboxSurvivesPublishFailure(t *testing.T) {
|
||||
id := bson.NewObjectID()
|
||||
out := &outbox{jobs: []model.Notification{{Base: storable.Base{ID: id}, Email: model.Email{Address: "private@example.com", Text: "private"}}}}
|
||||
transport := &publisher{fail: true}
|
||||
b := &Bus{outbox: out, transport: transport}
|
||||
if err := b.PublishDue(context.Background()); err == nil {
|
||||
t.Fatal("publication failure hidden")
|
||||
}
|
||||
transport.fail = false
|
||||
if err := b.PublishDue(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(transport.records) != 1 || string(transport.records[0].Payload) != id.Hex() || transport.records[0].MessageID != id.Hex()+"-0" {
|
||||
t.Fatal(transport.records)
|
||||
}
|
||||
out.jobs[0].Attempts = 1
|
||||
if err := b.PublishDue(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if transport.records[1].MessageID == transport.records[0].MessageID {
|
||||
t.Fatal("retry shares transport deduplication identity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
listen: "127.0.0.1:8083"
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
uri_env: SXCHNGE_MONGO_URI
|
||||
database: sxchnge_notifications
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: SXCHNGE_NATS_URL
|
||||
broker_name: sxchnge-notifications
|
||||
max_reconnects: -1
|
||||
reconnect_wait: 2
|
||||
templates: "templates.example.json"
|
||||
email:
|
||||
driver: resend # sendgrid or resend; see README for authenticated SMTP settings
|
||||
settings:
|
||||
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
||||
sender_address: "notifications@example.com"
|
||||
sender_name: "SXCHNGE"
|
||||
|
||||
# Use replicas: 3 and environment: production for a three-node NATS cluster.
|
||||
# mode: ops validates resources without modifying them; app provisions them.
|
||||
queue:
|
||||
stream:
|
||||
replicas: 1
|
||||
max_age_hours: 48
|
||||
provisioning:
|
||||
mode: app
|
||||
environment: non-production
|
||||
@@ -0,0 +1,88 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type Driver string
|
||||
|
||||
const (
|
||||
SendGrid Driver = "sendgrid"
|
||||
Resend Driver = "resend"
|
||||
SMTP Driver = "smtp"
|
||||
)
|
||||
|
||||
type Config = sharedmodel.DriverConfig[Driver]
|
||||
type Settings struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
SenderAddress string `mapstructure:"sender_address"`
|
||||
SenderName string `mapstructure:"sender_name"`
|
||||
}
|
||||
|
||||
// Outcome describes provider acceptance, never recipient delivery or reading.
|
||||
type Outcome struct {
|
||||
State model.State
|
||||
Code string
|
||||
RetryAfter time.Duration
|
||||
// Provider diagnostics are sanitized operational data, not public failures.
|
||||
HTTPStatus int
|
||||
SMTPStatus int
|
||||
ProviderError string
|
||||
ProviderMessage string
|
||||
}
|
||||
type Sender interface {
|
||||
Send(context.Context, string, model.Email) Outcome
|
||||
}
|
||||
|
||||
type client struct {
|
||||
http *http.Client
|
||||
key string
|
||||
settings Settings
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func New(config Config) (Sender, error) {
|
||||
if config.Driver == SMTP {
|
||||
return newSMTP(config)
|
||||
}
|
||||
var s Settings
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create driver settings decoder")
|
||||
}
|
||||
if err = decoder.Decode(config.Settings); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid email driver settings")
|
||||
}
|
||||
if !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") {
|
||||
return nil, merrors.InvalidArgument("invalid sender")
|
||||
}
|
||||
key := os.Getenv(s.APIKeyEnv)
|
||||
if s.APIKeyEnv == "" || strings.TrimSpace(key) == "" || strings.ContainsAny(key, "\r\n") {
|
||||
return nil, merrors.FailedPrecondition("email API key environment variable is missing")
|
||||
}
|
||||
c := client{http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, key: key, settings: s}
|
||||
switch config.Driver {
|
||||
case SendGrid:
|
||||
c.endpoint = "https://api.sendgrid.com/v3/mail/send"
|
||||
return &sendgrid{c}, nil
|
||||
case Resend:
|
||||
c.endpoint = "https://api.resend.com/emails"
|
||||
return &resend{c}, nil
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("unsupported email driver")
|
||||
}
|
||||
}
|
||||
func ValidAddress(s string) bool {
|
||||
a, err := mail.ParseAddress(s)
|
||||
return err == nil && a.Address == s && !strings.ContainsAny(s, "\r\n") && len(s) <= 254
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func TestDriversRenderWirePayload(t *testing.T) {
|
||||
for _, driver := range []Driver{SendGrid, Resend} {
|
||||
t.Run(string(driver), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
t.Error("missing authorization")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if body["subject"] != "Completed" {
|
||||
t.Errorf("subject: %v", body)
|
||||
}
|
||||
if driver == SendGrid {
|
||||
if body["template_id"] != nil {
|
||||
t.Error("provider-managed templates must not be used")
|
||||
}
|
||||
p := body["personalizations"].([]any)[0].(map[string]any)
|
||||
if p["to"].([]any)[0].(map[string]any)["email"] != "user@example.com" {
|
||||
t.Error("wrong recipient")
|
||||
}
|
||||
content := body["content"].([]any)
|
||||
if len(content) != 2 || content[1].(map[string]any)["value"] != "<p>A & B</p>" {
|
||||
t.Error("wrong body")
|
||||
}
|
||||
} else {
|
||||
if r.Header.Get("Idempotency-Key") != "delivery-id" {
|
||||
t.Error("missing idempotency key")
|
||||
}
|
||||
if body["html"] != "<p>A & B</p>" || body["text"] != "A & B" {
|
||||
t.Error("wrong body")
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), key: "test-secret", endpoint: server.URL, settings: Settings{SenderAddress: "sender@example.com", SenderName: "SXCHNGE"}}
|
||||
var sender Sender = &sendgrid{c}
|
||||
if driver == Resend {
|
||||
sender = &resend{c}
|
||||
}
|
||||
outcome := sender.Send(context.Background(), "delivery-id", model.Email{Address: "user@example.com", Subject: "Completed", HTML: "<p>A & B</p>", Text: "A & B"})
|
||||
if outcome.State != model.Accepted {
|
||||
t.Fatal(outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestProviderFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
code int
|
||||
state model.State
|
||||
}{{429, model.RetryWait}, {400, model.Failed}, {401, model.Failed}, {403, model.Failed}, {500, model.Unknown}, {408, model.Unknown}, {302, model.Failed}} {
|
||||
t.Run(http.StatusText(tc.code), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "120")
|
||||
w.WriteHeader(tc.code)
|
||||
_, _ = w.Write([]byte("secret provider response"))
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
out := c.post(context.Background(), "", struct{}{})
|
||||
if out.State != tc.state || strings.Contains(out.Code, "secret") {
|
||||
t.Fatal(out)
|
||||
}
|
||||
if tc.code == 429 && out.RetryAfter != 2*time.Minute {
|
||||
t.Fatal(out)
|
||||
}
|
||||
})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c := client{http: http.DefaultClient, endpoint: "http://127.0.0.1:1"}
|
||||
if out := c.post(ctx, "", struct{}{}); out.State != model.Unknown {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
func TestDriverConfig(t *testing.T) {
|
||||
t.Setenv("EMAIL_TEST_KEY", "secret")
|
||||
for _, driver := range []Driver{SendGrid, Resend} {
|
||||
if _, err := New(Config{Driver: driver, Settings: map[string]any{"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, settings := range []map[string]any{{"api_key_env": "MISSING_KEY", "sender_address": "sender@example.com"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "bad"}, {"api_key_env": "EMAIL_TEST_KEY", "sender_address": "sender@example.com", "typo": true}} {
|
||||
if _, err := New(Config{Driver: Resend, Settings: settings}); err == nil {
|
||||
t.Fatal("accepted invalid driver config")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxErrorBody = 16 << 10
|
||||
const maxErrorMessage = 512
|
||||
|
||||
var (
|
||||
errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`)
|
||||
addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`)
|
||||
urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
|
||||
tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`)
|
||||
numbers = regexp.MustCompile(`[0-9]{4,}`)
|
||||
)
|
||||
|
||||
// Only the documented human-readable error fields cross the adapter boundary.
|
||||
// Full bodies, headers and request data must never reach logs or public failures.
|
||||
func providerFailure(body, request []byte, secrets ...string) (string, string) {
|
||||
var dto struct {
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &dto); err != nil {
|
||||
return "", "Provider error details unavailable: invalid JSON response"
|
||||
}
|
||||
if dto.Message == "" && len(dto.Errors) > 0 {
|
||||
dto.Message = dto.Errors[0].Message
|
||||
}
|
||||
// Providers can echo any scalar string from their request DTOs. Strip those
|
||||
// values before truncation, in addition to credentials and embedded tokens.
|
||||
var payload any
|
||||
if json.Unmarshal(request, &payload) == nil {
|
||||
for _, value := range requestStrings(payload) {
|
||||
secrets = append(secrets, value)
|
||||
// Rendered login/verification codes and numeric account references
|
||||
// can be echoed as fragments of an otherwise longer email body.
|
||||
secrets = append(secrets, numbers.FindAllString(value, -1)...)
|
||||
}
|
||||
}
|
||||
name := safeErrorText(dto.Name, secrets)
|
||||
if !errorName.MatchString(name) {
|
||||
name = ""
|
||||
}
|
||||
message := safeErrorText(dto.Message, secrets)
|
||||
if message == "" {
|
||||
message = "Provider did not include an error message"
|
||||
}
|
||||
return name, message
|
||||
}
|
||||
|
||||
func requestStrings(value any) []string {
|
||||
var values []string
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
values = append(values, value)
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
values = append(values, requestStrings(item)...)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range value {
|
||||
values = append(values, requestStrings(item)...)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func safeErrorText(text string, secrets []string) string {
|
||||
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
|
||||
var replacements []string
|
||||
for _, secret := range secrets {
|
||||
if secret != "" {
|
||||
replacements = append(replacements, secret, "[redacted]")
|
||||
}
|
||||
}
|
||||
text = strings.NewReplacer(replacements...).Replace(text)
|
||||
text = addresses.ReplaceAllString(text, "[redacted-address]")
|
||||
text = urls.ReplaceAllString(text, "[redacted-url]")
|
||||
text = tokens.ReplaceAllString(text, "[redacted-token]")
|
||||
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r)
|
||||
}), " ")
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxErrorMessage {
|
||||
text = string(runes[:maxErrorMessage]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func TestProviderErrorDiagnostics(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"resend", `{"name":"validation_error","message":"The sxchnge.com domain is not verified"}`, "validation_error", "The sxchnge.com domain is not verified"},
|
||||
{"sendgrid", `{"errors":[{"message":"The sender identity is not verified","field":"from","help":"private URL"}]}`, "", "The sender identity is not verified"},
|
||||
{"no message", `{"unrecognized":"private response"}`, "", "Provider did not include an error message"},
|
||||
{"malformed", `{"message":"private response"`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"wrong type", `{"message":{"secret":"private response"}}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"trailing", `{"message":"private response"} {}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"oversized", `{"message":"` + strings.Repeat("x", maxErrorBody) + `"}`, "", "Provider error details unavailable: incomplete or oversized response"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
out := c.post(context.Background(), "", struct{}{})
|
||||
if out.State != model.Failed || out.Code != "provider_authentication_failed" || out.HTTPStatus != 403 || out.ProviderError != tc.code || out.ProviderMessage != tc.message {
|
||||
t.Fatalf("unexpected provider outcome: %+v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDiagnosticsRedactRequestDataBeforeTruncation(t *testing.T) {
|
||||
key := "re_credential-secret"
|
||||
request := []byte(`{"from":"Sender <sender@example.com>","to":["recipient@example.com"],"personalizations":[{"dynamic_template_data":{"secret":"short-private-value"}}],"subject":"Private subject","text":"Private text","html":"<p>Private HTML</p>","content":[{"value":"Your verification code is 123456; account 9876543210"}]}`)
|
||||
token := strings.Repeat("a", 43)
|
||||
message := "Credential " + key + "; recipient@example.com; Private subject; Private text; <p>Private HTML</p>; short-private-value; https://example.com/verify?token=" + token + "; " + token + "; 123456; 9876543210; job-secret\n\r\x00\u202eforged-log"
|
||||
body, err := json.Marshal(map[string]string{"name": "validation_error", "message": message})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, result := providerFailure(body, request, key, "job-secret")
|
||||
if name != "validation_error" || !strings.Contains(result, "forged-log") || !strings.Contains(result, "[redacted]") {
|
||||
t.Fatal("lost useful diagnostic", name, result)
|
||||
}
|
||||
for _, forbidden := range []string{key, "recipient@example.com", "Private subject", "Private text", "Private HTML", "short-private-value", "https://", token, "123456", "9876543210", "job-secret", "\n", "\r", "\x00", "\u202e"} {
|
||||
if strings.Contains(result, forbidden) {
|
||||
t.Fatalf("diagnostic contains sensitive value or control %q", forbidden)
|
||||
}
|
||||
}
|
||||
body, err = json.Marshal(map[string]string{"name": "validation_error", "message": strings.Repeat("ą", maxErrorMessage+20) + key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, result = providerFailure(body, request, key)
|
||||
if !utf8.ValidString(result) || utf8.RuneCountInString(result) != maxErrorMessage+1 || !strings.HasSuffix(result, "…") || strings.Contains(result, "re_") {
|
||||
t.Fatal("invalid or unbounded diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulResponsesDoNotExposeProviderContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"name":"private","message":"private provider response"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
if out := c.post(context.Background(), "", struct{}{}); out.State != model.Accepted || out.HTTPStatus != 200 || out.ProviderMessage != "" || out.ProviderError != "" {
|
||||
t.Fatal("success leaked provider content", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportFailureHasSanitizedDiagnostic(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c := client{http: http.DefaultClient, key: "credential-secret", endpoint: "https://example.com/emails?credential=credential-secret"}
|
||||
out := c.post(ctx, "job-secret", struct{}{})
|
||||
if out.State != model.Unknown || !strings.Contains(out.ProviderMessage, "context canceled") || strings.Contains(out.ProviderMessage, "credential-secret") || strings.Contains(out.ProviderMessage, "https://") {
|
||||
t.Fatal("transport cause missing or unsafe", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Failed, Code: "invalid_payload"}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Outcome{State: model.Failed, Code: "invalid_provider_config"}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Never pass raw provider bodies through the outcome or log them directly.
|
||||
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
|
||||
outcome := Outcome{HTTPStatus: resp.StatusCode}
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
outcome.State = model.Accepted
|
||||
case resp.StatusCode == 429:
|
||||
delay := time.Minute
|
||||
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
||||
delay = time.Duration(n) * time.Second
|
||||
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
||||
delay = time.Until(date)
|
||||
}
|
||||
outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
|
||||
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
||||
outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
|
||||
default:
|
||||
outcome.State, outcome.Code = model.Failed, "provider_rejected"
|
||||
}
|
||||
if outcome.State != model.Accepted {
|
||||
if readErr != nil || len(details) > maxErrorBody {
|
||||
outcome.ProviderMessage = "Provider error details unavailable: incomplete or oversized response"
|
||||
} else {
|
||||
outcome.ProviderError, outcome.ProviderMessage = providerFailure(details, body, c.key, key)
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func smtpMessage(s smtpSettings, key string, email model.Email) ([]byte, error) {
|
||||
if !ValidAddress(email.Address) || strings.ContainsAny(email.Subject, "\r\n") || (email.Text == "" && email.HTML == "") {
|
||||
return nil, merrors.InvalidArgument("invalid email content")
|
||||
}
|
||||
var body bytes.Buffer
|
||||
parts := multipart.NewWriter(&body)
|
||||
for _, part := range []struct{ kind, value string }{{"text/plain", email.Text}, {"text/html", email.HTML}} {
|
||||
if part.value == "" {
|
||||
continue
|
||||
}
|
||||
writer, err := parts.CreatePart(textproto.MIMEHeader{"Content-Type": {part.kind + "; charset=utf-8"}, "Content-Transfer-Encoding": {"quoted-printable"}})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create email MIME part")
|
||||
}
|
||||
encoded := quotedprintable.NewWriter(writer)
|
||||
if _, err = encoded.Write([]byte(part.value)); err != nil {
|
||||
return nil, merrors.InternalWrap(err, "encode email MIME part")
|
||||
}
|
||||
if err = encoded.Close(); err != nil {
|
||||
return nil, merrors.InternalWrap(err, "finish email MIME part")
|
||||
}
|
||||
}
|
||||
if err := parts.Close(); err != nil {
|
||||
return nil, merrors.InternalWrap(err, "finish email MIME body")
|
||||
}
|
||||
from := (&mail.Address{Name: s.SenderName, Address: s.SenderAddress}).String()
|
||||
to := (&mail.Address{Address: email.Address}).String()
|
||||
domain := s.SenderAddress[strings.LastIndex(s.SenderAddress, "@")+1:]
|
||||
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%x@%s>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("utf-8", email.Subject), time.Now().UTC().Format(time.RFC1123Z), sha256.Sum256([]byte(key)), domain, parts.Boundary())
|
||||
return append([]byte(headers), body.Bytes()...), nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
type resend struct{ client }
|
||||
type resendRequest struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func (s *resend) Send(ctx context.Context, key string, email model.Email) Outcome {
|
||||
from := (&mail.Address{Name: s.settings.SenderName, Address: s.settings.SenderAddress}).String()
|
||||
return s.post(ctx, key, resendRequest{From: from, To: []string{email.Address}, Subject: email.Subject, HTML: email.HTML, Text: email.Text})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package email
|
||||
|
||||
// Adapted from Sendico's SendGrid notifier (see PROVENANCE.md). The SDK mail
|
||||
// construction is retained; templates, retries and logging belong to our service.
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"github.com/sendgrid/sendgrid-go/helpers/mail"
|
||||
)
|
||||
|
||||
type sendgrid struct{ client }
|
||||
|
||||
func (s *sendgrid) Send(ctx context.Context, _ string, email model.Email) Outcome {
|
||||
m := mail.NewV3Mail()
|
||||
m.SetFrom(mail.NewEmail(s.settings.SenderName, s.settings.SenderAddress))
|
||||
m.Subject = email.Subject
|
||||
p := mail.NewPersonalization()
|
||||
p.AddTos(mail.NewEmail("", email.Address))
|
||||
m.AddPersonalizations(p)
|
||||
if email.Text != "" {
|
||||
m.AddContent(mail.NewContent("text/plain", email.Text))
|
||||
}
|
||||
if email.HTML != "" {
|
||||
m.AddContent(mail.NewContent("text/html", email.HTML))
|
||||
}
|
||||
return s.post(ctx, "", m)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
type smtpSettings struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
PasswordEnv string `mapstructure:"password_env"`
|
||||
SenderAddress string `mapstructure:"sender_address"`
|
||||
SenderName string `mapstructure:"sender_name"`
|
||||
NetworkTimeout int `mapstructure:"network_timeout"`
|
||||
}
|
||||
|
||||
type smtpSender struct {
|
||||
settings smtpSettings
|
||||
password string
|
||||
tls *tls.Config
|
||||
}
|
||||
|
||||
func newSMTP(config Config) (Sender, error) {
|
||||
var s smtpSettings
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create SMTP settings decoder")
|
||||
}
|
||||
if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 {
|
||||
return nil, merrors.InvalidArgument("invalid SMTP settings")
|
||||
}
|
||||
password := os.Getenv(s.PasswordEnv)
|
||||
if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") {
|
||||
return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid")
|
||||
}
|
||||
return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil
|
||||
}
|
||||
|
||||
// SMTP uses a fresh authenticated TLS connection per attempt. No automatic
|
||||
// resend follows an uncertain result, since SMTP has no idempotency guarantee.
|
||||
func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome {
|
||||
body, err := smtpMessage(s.settings, key, email)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Failed, Code: "invalid_payload"}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second)
|
||||
defer cancel()
|
||||
dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port)))
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
deadline, _ := ctx.Deadline()
|
||||
if err = conn.SetDeadline(deadline); err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
client, err := smtp.NewClient(conn, s.settings.Host)
|
||||
if err == nil {
|
||||
err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host))
|
||||
}
|
||||
if err == nil {
|
||||
err = client.Mail(s.settings.SenderAddress)
|
||||
}
|
||||
if err == nil {
|
||||
err = client.Rcpt(email.Address)
|
||||
}
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil {
|
||||
err = writer.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return s.failure(err, key, email)
|
||||
}
|
||||
// DATA's final 250 is acceptance. A later QUIT error cannot undo it.
|
||||
_ = client.Quit()
|
||||
return Outcome{State: model.Accepted, SMTPStatus: 250}
|
||||
}
|
||||
|
||||
func (s *smtpSender) failure(err error, key string, email model.Email) Outcome {
|
||||
secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text}
|
||||
secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password)))
|
||||
secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...)
|
||||
out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)}
|
||||
var reply *textproto.Error
|
||||
if errors.As(err, &reply) {
|
||||
out.SMTPStatus = reply.Code
|
||||
if reply.Code >= 400 && reply.Code < 500 {
|
||||
out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute
|
||||
} else if reply.Code >= 500 && reply.Code < 600 {
|
||||
out.State, out.Code = model.Failed, "provider_rejected"
|
||||
if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 {
|
||||
out.Code = "provider_authentication_failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
type smtpFixture struct {
|
||||
settings smtpSettings
|
||||
root *x509.CertPool
|
||||
body chan []byte
|
||||
auth atomic.Bool
|
||||
}
|
||||
|
||||
func smtpServer(t *testing.T, reject string, code int) *smtpFixture {
|
||||
t.Helper()
|
||||
certServer := httptest.NewTLSServer(nil)
|
||||
cert := certServer.TLS.Certificates[0]
|
||||
root := x509.NewCertPool()
|
||||
root.AddCert(certServer.Certificate())
|
||||
certServer.Close()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
host, port, _ := net.SplitHostPort(listener.Addr().String())
|
||||
n, _ := strconv.Atoi(port)
|
||||
f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)}
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
if reject == "greeting" {
|
||||
_ = conn.(*tls.Conn).Handshake()
|
||||
_, _ = io.Copy(io.Discard, conn)
|
||||
return
|
||||
}
|
||||
c := textproto.NewConn(conn)
|
||||
if c.PrintfLine("220 localhost ESMTP") != nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, err := c.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
command := strings.SplitN(line, " ", 2)[0]
|
||||
if command == reject {
|
||||
_ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code)
|
||||
continue
|
||||
}
|
||||
switch command {
|
||||
case "EHLO":
|
||||
_ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
|
||||
case "AUTH":
|
||||
want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password"))
|
||||
f.auth.Store(line == want)
|
||||
_ = c.PrintfLine("235 authenticated")
|
||||
case "MAIL", "RCPT":
|
||||
_ = c.PrintfLine("250 OK")
|
||||
case "DATA":
|
||||
_ = c.PrintfLine("354 send message")
|
||||
body, err := c.ReadDotBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.body <- body
|
||||
if reject == "final" {
|
||||
_ = c.PrintfLine("%d final rejection", code)
|
||||
} else if reject == "disconnect" {
|
||||
return
|
||||
} else {
|
||||
_ = c.PrintfLine("250 queued")
|
||||
}
|
||||
case "QUIT":
|
||||
// A lost QUIT reply must not turn accepted DATA into UNKNOWN.
|
||||
return
|
||||
default:
|
||||
_ = c.PrintfLine("500 unexpected command")
|
||||
}
|
||||
}
|
||||
}()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *smtpFixture) sender() *smtpSender {
|
||||
return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}}
|
||||
}
|
||||
|
||||
func TestSMTPAcceptanceAndMIME(t *testing.T) {
|
||||
f := smtpServer(t, "", 0)
|
||||
sender := f.sender()
|
||||
email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "<p>Confirm code 123456</p>"}
|
||||
out := sender.Send(context.Background(), "notification-id", email)
|
||||
if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() {
|
||||
t.Fatal("SMTP acceptance/authentication failed", out)
|
||||
}
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
|
||||
if err != nil || subject != email.Subject {
|
||||
t.Fatal("wrong MIME subject", subject, err)
|
||||
}
|
||||
if msg.Header.Get("From") != "\"SXCHNGE\" <sender@example.com>" || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") {
|
||||
t.Fatal("invalid sender/message identifier")
|
||||
}
|
||||
kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil || kind != "multipart/alternative" {
|
||||
t.Fatal("wrong MIME type", kind, err)
|
||||
}
|
||||
parts := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for _, want := range []string{email.Text, email.HTML} {
|
||||
part, err := parts.NextPart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want {
|
||||
t.Fatal("incorrect MIME content", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPFailureSafety(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
stage string
|
||||
code int
|
||||
state model.State
|
||||
failure string
|
||||
}{
|
||||
{"AUTH", 535, model.Failed, "provider_authentication_failed"},
|
||||
{"RCPT", 550, model.Failed, "provider_rejected"},
|
||||
{"final", 451, model.RetryWait, "provider_temporarily_unavailable"},
|
||||
{"disconnect", 0, model.Unknown, "provider_outcome_unknown"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) {
|
||||
f := smtpServer(t, tc.stage, tc.code)
|
||||
out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"})
|
||||
if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code {
|
||||
t.Fatal("incorrect SMTP classification", out)
|
||||
}
|
||||
if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") {
|
||||
t.Fatal("SMTP rejection leaked private data")
|
||||
}
|
||||
if tc.code == 451 && out.RetryAfter <= 0 {
|
||||
t.Fatal("temporary rejection did not schedule retry")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPCertificateAndCancellation(t *testing.T) {
|
||||
t.Run("untrusted certificate", func(t *testing.T) {
|
||||
f := smtpServer(t, "", 0)
|
||||
sender := f.sender()
|
||||
sender.tls.RootCAs = x509.NewCertPool()
|
||||
if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() {
|
||||
t.Fatal("untrusted TLS accepted", out)
|
||||
}
|
||||
})
|
||||
t.Run("cancel blocked greeting", func(t *testing.T) {
|
||||
f := smtpServer(t, "greeting", 0)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second {
|
||||
t.Fatal("SMTP ignored cancellation", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSMTPConfigAndHeaderValidation(t *testing.T) {
|
||||
t.Setenv("TEST_SMTP_PASSWORD", "test-password")
|
||||
valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10}
|
||||
if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} {
|
||||
settings := map[string]any{}
|
||||
for key, item := range valid {
|
||||
settings[key] = item
|
||||
}
|
||||
settings[field] = value
|
||||
if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil {
|
||||
t.Fatalf("invalid setting %s accepted", field)
|
||||
}
|
||||
}
|
||||
if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil {
|
||||
t.Fatal("header injection accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) {
|
||||
f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"}
|
||||
auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a"))
|
||||
out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{})
|
||||
if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") {
|
||||
t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
module git.sendico.io/tech/sxchnge/notifications
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.55.0 // indirect
|
||||
github.com/moby/moby/client v0.5.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/nats-io/nats.go v1.53.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
|
||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||
|
||||
require (
|
||||
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
|
||||
git.sendico.io/tech/sxchnge/shared v0.0.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.9.0
|
||||
go.uber.org/zap v1.28.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
replace git.sendico.io/tech/sxchnge/notifications/interface => ./interface
|
||||
@@ -0,0 +1,203 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
|
||||
github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs=
|
||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0 h1:VSPDFiumAtt0CkZEVbmAkEmYVRvsJpKJy9oF3exRKYg=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.44.0/go.mod h1:kHfzrY1cYP/zr9H4TdqAxbP836A1C2fyUojlHidhFGI=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.9.0 h1:e2mQdOmbkiYz+dj3faM7lVDwl7WdnRD+g5VicafMhL0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.9.0/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
@@ -0,0 +1,41 @@
|
||||
# Notifications service contract
|
||||
|
||||
This independent module owns the public internal-service contract. The canonical
|
||||
source is `proto/v1/interface.proto`; it defines `Send`, `Get`, requests, statuses
|
||||
and email/SMS/push/Telegram targets. The executable currently implements email.
|
||||
|
||||
From the repository root, `make generate-notifications` produces Go/gRPC client
|
||||
bindings in `notifications/v1`. Generated `.pb.go` files are ignored and must not
|
||||
be committed. Both backend validation targets generate bindings before compiling.
|
||||
The generators are pinned in the Makefile; install `protoc` separately.
|
||||
|
||||
`make package-notifications-interface` creates a distributable source archive and
|
||||
a standalone Go client-module archive under `dist/`. Client consumers can generate
|
||||
bindings for their own language from the source archive. The Go archive contains
|
||||
the generated bindings and its own go.mod/go.sum, with no executable or shared
|
||||
infrastructure dependency. Archive generation does not upload to a registry.
|
||||
|
||||
Internal Go consumers use the module
|
||||
`git.sendico.io/tech/sxchnge/notifications/interface` and import
|
||||
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
|
||||
checkout, use a local replace to this directory and generate before building.
|
||||
|
||||
Calls use the trusted private network without caller authentication. The service
|
||||
uses the existing product storage namespace. Callers authorize user/destination
|
||||
ownership and preferences before sending; this contract is not a public user API.
|
||||
|
||||
Parameters are string values keyed by template parameter name. The entire request
|
||||
is validated before persistence. An identical idempotency key/request returns the
|
||||
original resource, even after completion or expiry. A changed request conflicts.
|
||||
Keys are scoped to the product namespace and retained at least seven days;
|
||||
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
|
||||
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
|
||||
|
||||
ACCEPTED means provider acceptance, not delivery or reading. UNKNOWN means the
|
||||
provider may have sent the message and automatic replay is unsafe. DELIVERED is
|
||||
reserved for a future receipt implementation. Get never exposes recipient addresses,
|
||||
rendered content, parameters, provider credentials or raw provider responses.
|
||||
|
||||
Keep published template revisions and field numbers stable. Reserve removed
|
||||
protobuf field/enum names and numbers. Compatible additions belong in v1;
|
||||
incompatible changes require a new version.
|
||||
@@ -0,0 +1,15 @@
|
||||
module git.sendico.io/tech/sxchnge/notifications/interface
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.12
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -0,0 +1,73 @@
|
||||
syntax = "proto3";
|
||||
package sxchnge.notifications.v1;
|
||||
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Internal service on the trusted private network; no caller authentication. Send durably queues work.
|
||||
service NotificationsService {
|
||||
rpc Send(SendRequest) returns (SendResponse);
|
||||
rpc Get(GetRequest) returns (GetResponse);
|
||||
}
|
||||
message SendRequest {
|
||||
string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
|
||||
string user_id = 2; // Caller resolves and authorizes recipient ownership.
|
||||
string template_id = 3;
|
||||
string template_version = 4;
|
||||
map<string, string> parameters = 5;
|
||||
string locale = 6;
|
||||
repeated Target targets = 7; // V1 accepts exactly one email target.
|
||||
google.protobuf.Timestamp expires_at = 8; // Default/max 24h from acceptance.
|
||||
}
|
||||
message Target {
|
||||
oneof destination {
|
||||
EmailTarget email = 1;
|
||||
SmsTarget sms = 2;
|
||||
PushTarget push = 3;
|
||||
TelegramTarget telegram = 4;
|
||||
}
|
||||
}
|
||||
message EmailTarget { string address = 1; }
|
||||
message SmsTarget { string phone_number = 1; }
|
||||
message PushTarget { string endpoint_id = 1; }
|
||||
message TelegramTarget { string chat_id = 1; }
|
||||
message SendResponse { Notification notification = 1; }
|
||||
message GetRequest { string id = 1; }
|
||||
message GetResponse { Notification notification = 1; }
|
||||
message Notification {
|
||||
string id = 1;
|
||||
string user_id = 2;
|
||||
string template_id = 3;
|
||||
string template_version = 4;
|
||||
repeated Delivery deliveries = 5;
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
google.protobuf.Timestamp updated_at = 7;
|
||||
google.protobuf.Timestamp expires_at = 8;
|
||||
}
|
||||
message Delivery {
|
||||
string id = 1;
|
||||
Channel channel = 2;
|
||||
DeliveryState state = 3;
|
||||
uint32 attempt_count = 4;
|
||||
Failure failure = 5;
|
||||
google.protobuf.Timestamp next_attempt_at = 6;
|
||||
google.protobuf.Timestamp updated_at = 7;
|
||||
}
|
||||
message Failure { string code = 1; string message = 2; }
|
||||
enum Channel {
|
||||
CHANNEL_UNSPECIFIED = 0;
|
||||
CHANNEL_EMAIL = 1;
|
||||
CHANNEL_SMS = 2;
|
||||
CHANNEL_PUSH = 3;
|
||||
CHANNEL_TELEGRAM = 4;
|
||||
}
|
||||
enum DeliveryState {
|
||||
DELIVERY_STATE_UNSPECIFIED = 0;
|
||||
DELIVERY_STATE_QUEUED = 1;
|
||||
DELIVERY_STATE_SENDING = 2;
|
||||
DELIVERY_STATE_RETRY_WAIT = 3;
|
||||
DELIVERY_STATE_ACCEPTED = 4;
|
||||
DELIVERY_STATE_DELIVERED = 5; // Reserved for future verified receipts.
|
||||
DELIVERY_STATE_FAILED = 6;
|
||||
DELIVERY_STATE_EXPIRED = 7;
|
||||
DELIVERY_STATE_UNKNOWN = 8; // May have sent; no automatic replay.
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/app"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := flag.String("config.file", "config.yml", "notification configuration")
|
||||
flag.Parse()
|
||||
logger, err := zap.NewProduction()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
config, err := app.Load(*path)
|
||||
if err == nil {
|
||||
err = app.Run(ctx, config, logger)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("Notifications service terminated", zap.Error(err))
|
||||
_ = logger.Sync()
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = logger.Sync()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
Queued State = "queued"
|
||||
Sending State = "sending"
|
||||
RetryWait State = "retry_wait"
|
||||
Accepted State = "accepted"
|
||||
Failed State = "failed"
|
||||
Expired State = "expired"
|
||||
Unknown State = "unknown"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Key string
|
||||
UserID string
|
||||
TemplateID string
|
||||
TemplateVersion string
|
||||
Parameters map[string]string
|
||||
Locale string
|
||||
Address string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Email is a rendered immutable snapshot, independent of provider wire formats.
|
||||
type Email struct {
|
||||
Address string
|
||||
Subject string
|
||||
HTML string
|
||||
Text string
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Caller string `bson:"caller"`
|
||||
Key string `bson:"key"`
|
||||
Fingerprint string `bson:"fingerprint"`
|
||||
UserID string `bson:"user_id"`
|
||||
TemplateID string `bson:"template_id"`
|
||||
TemplateVersion string `bson:"template_version"`
|
||||
Email Email `bson:"email"`
|
||||
Driver string `bson:"driver"`
|
||||
State State `bson:"state"`
|
||||
Attempts uint32 `bson:"attempts"`
|
||||
FailureCode string `bson:"failure_code"`
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
NextAttemptAt time.Time `bson:"next_attempt_at"`
|
||||
LeaseUntil time.Time `bson:"lease_until"`
|
||||
PurgeAt time.Time `bson:"purge_at"`
|
||||
}
|
||||
|
||||
func (*Notification) Collection() string { return "notifications" }
|
||||
@@ -0,0 +1,53 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/service"
|
||||
"git.sendico.io/tech/sxchnge/notifications/transport"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// Exercise optional TLS transport against the real service and storage.
|
||||
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), DNSNames: []string{"localhost"}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(certificate)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})))
|
||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.Stop)
|
||||
conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots, ServerName: "localhost"})))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return pb.NewNotificationsServiceClient(conn)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/bus"
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"git.sendico.io/tech/sxchnge/notifications/service"
|
||||
"git.sendico.io/tech/sxchnge/notifications/store"
|
||||
"git.sendico.io/tech/sxchnge/notifications/templates"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
msgruntime "git.sendico.io/tech/sxchnge/shared/messaging/runtime"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type sender struct {
|
||||
calls atomic.Int32
|
||||
mu sync.Mutex
|
||||
messages map[string]model.Email
|
||||
state model.State
|
||||
}
|
||||
|
||||
func (s *sender) Send(_ context.Context, id string, message model.Email) email.Outcome {
|
||||
s.mu.Lock()
|
||||
if s.messages == nil {
|
||||
s.messages = map[string]model.Email{}
|
||||
}
|
||||
s.messages[id] = message
|
||||
s.mu.Unlock()
|
||||
s.calls.Add(1)
|
||||
return email.Outcome{State: s.state, Code: "test", RetryAfter: time.Millisecond}
|
||||
}
|
||||
func TestDurableDelivery(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = container.Terminate(context.Background()) }()
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := db.ConnectMongo(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: map[string]any{"uri": uri, "database": "notifications_test"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Disconnect(context.Background())
|
||||
repo, err := store.New(zap.NewNop(), conn.Database())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := templates.New([]templates.Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]templates.Content{"en": {Subject: "Hello", HTML: "<p>{{.name}}</p>"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver := &sender{state: model.Accepted}
|
||||
svc := service.New(repo, catalog, driver, "resend", zap.NewNop())
|
||||
request := model.Request{Key: "duplicate", UserID: "u", TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "A & B"}, Address: "user@example.com"}
|
||||
var wg sync.WaitGroup
|
||||
ids := make(chan string, 10)
|
||||
for range 10 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
n, e := svc.Send(ctx, "product", request)
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
return
|
||||
}
|
||||
ids <- n.ID.Hex()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(ids)
|
||||
id := ""
|
||||
for value := range ids {
|
||||
if id != "" && value != id {
|
||||
t.Error("idempotency race")
|
||||
}
|
||||
id = value
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("no notification")
|
||||
}
|
||||
request.Parameters = map[string]string{"name": "Changed"}
|
||||
if _, err = svc.Send(ctx, "product", request); !errors.Is(err, merrors.ErrDataConflict) {
|
||||
t.Fatal("missing conflict", err)
|
||||
}
|
||||
if _, err = svc.Get(ctx, "other", id); !errors.Is(err, merrors.ErrNoData) {
|
||||
t.Fatal("caller isolation failed", err)
|
||||
}
|
||||
stored, err := svc.Get(ctx, "product", id)
|
||||
if err != nil || stored.Email.HTML != "<p>A & B</p>" {
|
||||
t.Fatal(stored, err)
|
||||
}
|
||||
for range 10 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, e := svc.Dispatch(ctx, id); e != nil {
|
||||
t.Error(e)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if driver.calls.Load() != 1 {
|
||||
t.Fatalf("sent %d times", driver.calls.Load())
|
||||
}
|
||||
stored, err = svc.Get(ctx, "product", id)
|
||||
if err != nil || stored.State != model.Accepted || stored.Email.Address != "" {
|
||||
t.Fatal("completion/scrubbing failed", stored, err)
|
||||
}
|
||||
// A stale sending lease is never automatically resent.
|
||||
request.Key = "stale"
|
||||
stale, err := svc.Send(ctx, "product", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-2 * time.Minute)
|
||||
if err = repo.DBImp.Patch(ctx, stale.ID, repository.Patch().Set(repository.Field("next_attempt_at"), old)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := repo.Claim(ctx, stale.ID.Hex(), old)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.Recover(ctx, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed.State = model.Accepted
|
||||
if err = repo.Finish(ctx, claimed); !errors.Is(err, merrors.ErrDataConflict) {
|
||||
t.Fatal("stale worker overwrote recovery", err)
|
||||
}
|
||||
stale, err = svc.Get(ctx, "product", stale.ID.Hex())
|
||||
if err != nil || stale.State != model.Unknown {
|
||||
t.Fatal(stale, err)
|
||||
}
|
||||
// Retry state persists, and a duplicate dispatch cannot bypass the due time.
|
||||
request.Key = "retry"
|
||||
retry, err := svc.Send(ctx, "product", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retryDriver := &sender{state: model.RetryWait}
|
||||
retrySvc := service.New(repo, catalog, retryDriver, "resend", zap.NewNop())
|
||||
if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = retrySvc.Dispatch(ctx, retry.ID.Hex()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err = svc.Get(ctx, "product", retry.ID.Hex())
|
||||
if err != nil || retry.State != model.RetryWait || retryDriver.calls.Load() != 1 {
|
||||
t.Fatal(retry, err)
|
||||
}
|
||||
// Expire both queued and retrying notifications without a connected broker.
|
||||
for _, state := range []model.State{model.Queued, model.RetryWait} {
|
||||
request.Key = "offline-expiry-" + string(state)
|
||||
expired, e := svc.Send(ctx, "product", request)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
patch := repository.Patch().Set(repository.Field("state"), state).Set(repository.Field("expires_at"), time.Now().Add(-time.Minute)).Set(repository.Field("next_attempt_at"), time.Now().Add(time.Hour))
|
||||
if e = repo.DBImp.Patch(ctx, expired.ID, patch); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = repo.Recover(ctx, time.Now()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
expired, e = svc.Get(ctx, "product", expired.ID.Hex())
|
||||
if e != nil || expired.State != model.Expired || expired.Email.Address != "" {
|
||||
t.Fatal("offline expiry failed", expired, e)
|
||||
}
|
||||
}
|
||||
// Verify the real JetStream publisher/consumer path with an isolated broker.
|
||||
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:latest", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ncContainer.Terminate(context.Background())
|
||||
endpoint, err := ncContainer.Endpoint(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("TEST_NOTIFICATIONS_NATS", "nats://"+endpoint)
|
||||
rt, err := msgruntime.New(zap.NewNop(), &messaging.Config{Driver: messaging.BBNats, Settings: map[string]any{"url_env": "TEST_NOTIFICATIONS_NATS", "broker_name": "test", "max_reconnects": -1, "reconnect_wait": 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rt.Close()
|
||||
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, zap.NewNop(), bus.DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer broker.Close()
|
||||
// Production ops mode validates existing resources without silently rewriting drift.
|
||||
queue := bus.DefaultConfig()
|
||||
settings := reliable.DurableSettings{Stream: queue.Stream, Provisioning: reliable.DurableProvisioningSettings{Mode: reliable.DurableProvisioningOps, Environment: reliable.DurableEnvironmentProduction}}
|
||||
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Stream.MaxAgeHours = 72
|
||||
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); !errors.Is(err, merrors.ErrDataConflict) {
|
||||
t.Fatal("retention drift accepted", err)
|
||||
}
|
||||
settings.Stream.MaxAgeHours = 48
|
||||
if err = rt.DurableAdmin.EnsureProducerResources(ctx, settings); err != nil {
|
||||
t.Fatal("ops mode mutated the stream", err)
|
||||
}
|
||||
canceled, stopAdmin := context.WithCancel(ctx)
|
||||
stopAdmin()
|
||||
if err = rt.DurableAdmin.EnsureProducerResources(canceled, settings); err == nil {
|
||||
t.Fatal("canceled provisioning succeeded")
|
||||
}
|
||||
|
||||
client := grpcClient(t, svc)
|
||||
rpcCtx := ctx
|
||||
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
||||
sent, err := client.Send(rpcCtx, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay, err := client.Send(rpcCtx, input)
|
||||
if err != nil || replay.Notification.Id != sent.Notification.Id {
|
||||
t.Fatal("RPC idempotency failed", err)
|
||||
}
|
||||
if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
|
||||
t.Fatal("RPC retrieval without credentials failed", e)
|
||||
}
|
||||
if err = broker.PublishDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
go func() { broker.RunWorker(workerCtx); close(done) }()
|
||||
defer func() { stop(); <-done }()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
result, e := client.Get(rpcCtx, &pb.GetRequest{Id: sent.Notification.Id})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if strings.Contains(result.String(), "user@example.com") || strings.Contains(result.String(), "escaped") {
|
||||
t.Fatal("response exposes email content")
|
||||
}
|
||||
if result.Notification.Deliveries[0].State == pb.DeliveryState_DELIVERY_STATE_ACCEPTED {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("JetStream job did not complete", result.Notification.Deliveries[0].State)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
driver.mu.Lock()
|
||||
delivered := driver.messages[sent.Notification.Id]
|
||||
driver.mu.Unlock()
|
||||
if delivered.HTML != "<p>End to end & escaped</p>" || delivered.Subject != "Hello" || delivered.Address != "user@example.com" {
|
||||
t.Fatal("parameters did not reach driver", delivered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"git.sendico.io/tech/sxchnge/notifications/templates"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Find(context.Context, string, string) (*model.Notification, error)
|
||||
Get(context.Context, string, string) (*model.Notification, error)
|
||||
Create(context.Context, *model.Notification) (*model.Notification, error)
|
||||
Claim(context.Context, string, time.Time) (*model.Notification, error)
|
||||
Finish(context.Context, *model.Notification) error
|
||||
Recover(context.Context, time.Time) error
|
||||
}
|
||||
type Service struct {
|
||||
repo Repository
|
||||
catalog *templates.Catalog
|
||||
sender email.Sender
|
||||
driver string
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(repo Repository, catalog *templates.Catalog, sender email.Sender, driver string, logger mlogger.Logger) *Service {
|
||||
return &Service{repo, catalog, sender, driver, mlogger.Named(logger, "notifications")}
|
||||
}
|
||||
func (s *Service) Send(ctx context.Context, caller string, r model.Request) (*model.Notification, error) {
|
||||
if caller == "" {
|
||||
return nil, merrors.Unauthorized("missing caller")
|
||||
}
|
||||
if r.Key == "" || len(r.Key) > 128 || r.UserID == "" || len(r.UserID) > 128 || !email.ValidAddress(r.Address) || len(r.TemplateID) > 128 || len(r.TemplateVersion) > 64 || len(r.Locale) > 64 || strings.ContainsRune(r.TemplateID+r.TemplateVersion, 0) {
|
||||
return nil, merrors.InvalidArgument("invalid notification request")
|
||||
}
|
||||
if len(r.Parameters) > 64 {
|
||||
return nil, merrors.InvalidArgument("too many parameters")
|
||||
}
|
||||
for k, v := range r.Parameters {
|
||||
if k == "" || len(k) > 64 || len(v) > 4096 {
|
||||
return nil, merrors.InvalidArgument("invalid template parameter")
|
||||
}
|
||||
}
|
||||
payload, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
if len(payload) > 64<<10 {
|
||||
return nil, merrors.InvalidArgument("request too large")
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
fingerprint := hex.EncodeToString(sum[:])
|
||||
existing, err := s.repo.Find(ctx, caller, r.Key)
|
||||
if err == nil {
|
||||
return same(existing, fingerprint)
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
expiry := r.ExpiresAt
|
||||
if expiry.IsZero() {
|
||||
expiry = now.Add(24 * time.Hour)
|
||||
}
|
||||
if !expiry.After(now) || expiry.After(now.Add(24*time.Hour)) {
|
||||
return nil, merrors.InvalidArgument("expiry must be within the next 24 hours")
|
||||
}
|
||||
rendered, err := s.catalog.Render(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: now, UpdatedAt: now}, Caller: caller, Key: r.Key, Fingerprint: fingerprint, UserID: r.UserID, TemplateID: r.TemplateID, TemplateVersion: r.TemplateVersion, Email: rendered, Driver: s.driver, State: model.Queued, ExpiresAt: expiry, NextAttemptAt: now, PurgeAt: now.Add(7 * 24 * time.Hour)}
|
||||
stored, err := s.repo.Create(ctx, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := same(stored, fingerprint)
|
||||
if err == nil {
|
||||
s.logger.Info("Notification accepted", zap.String("notification_id", stored.ID.Hex()), zap.String("caller", caller))
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
func same(n *model.Notification, fingerprint string) (*model.Notification, error) {
|
||||
if n.Fingerprint != fingerprint {
|
||||
return nil, merrors.DataConflict("idempotency key already used with a different request")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
|
||||
if caller == "" {
|
||||
return nil, merrors.Unauthorized("missing caller")
|
||||
}
|
||||
if _, err := bson.ObjectIDFromHex(id); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid notification ID")
|
||||
}
|
||||
return s.repo.Get(ctx, caller, id)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Dispatch consumes an opaque job ID from JetStream. Duplicate messages are safe.
|
||||
func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
||||
now := time.Now().UTC()
|
||||
if err := s.repo.Recover(ctx, now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := s.repo.Claim(ctx, id, now)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var outcome email.Outcome
|
||||
switch {
|
||||
case !n.ExpiresAt.After(now):
|
||||
n.State = model.Expired
|
||||
n.FailureCode = "expired"
|
||||
case n.Driver != s.driver:
|
||||
n.State = model.Failed
|
||||
n.FailureCode = "driver_changed"
|
||||
default:
|
||||
deadline := now.Add(20 * time.Second)
|
||||
if n.ExpiresAt.Before(deadline) {
|
||||
deadline = n.ExpiresAt
|
||||
}
|
||||
sendCtx, cancel := context.WithDeadline(ctx, deadline)
|
||||
outcome = s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
|
||||
cancel()
|
||||
n.State = outcome.State
|
||||
n.FailureCode = outcome.Code
|
||||
if n.State == model.RetryWait {
|
||||
delay := time.Duration(1<<min(n.Attempts, 8)) * time.Second
|
||||
if outcome.RetryAfter > delay {
|
||||
delay = outcome.RetryAfter
|
||||
}
|
||||
n.NextAttemptAt = time.Now().UTC().Add(delay)
|
||||
if n.Attempts >= 5 {
|
||||
n.State = model.Failed
|
||||
n.FailureCode = "retry_exhausted"
|
||||
} else if !n.NextAttemptAt.Before(n.ExpiresAt) {
|
||||
n.State = model.Expired
|
||||
n.FailureCode = "expired"
|
||||
}
|
||||
} else if n.State != model.Accepted && n.State != model.Failed && n.State != model.Unknown {
|
||||
n.State = model.Unknown
|
||||
n.FailureCode = "invalid_driver_outcome"
|
||||
}
|
||||
}
|
||||
n.UpdatedAt = time.Now().UTC()
|
||||
// Persist the result even when shutdown canceled the provider request.
|
||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
fields := []zap.Field{zap.String("notification_id", n.ID.Hex()), zap.String("driver", n.Driver), zap.String("state", string(n.State)), zap.Uint32("attempts", n.Attempts), zap.String("failure_code", n.FailureCode)}
|
||||
if outcome.HTTPStatus != 0 {
|
||||
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
|
||||
}
|
||||
if outcome.SMTPStatus != 0 {
|
||||
fields = append(fields, zap.Int("provider_smtp_status", outcome.SMTPStatus))
|
||||
}
|
||||
if outcome.ProviderError != "" {
|
||||
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
|
||||
}
|
||||
if outcome.ProviderMessage != "" {
|
||||
fields = append(fields, zap.String("provider_message", outcome.ProviderMessage))
|
||||
}
|
||||
// Keep the provider outcome observable even if its local result write fails.
|
||||
if n.State == model.Accepted {
|
||||
s.logger.Info("Email accepted by provider", fields...)
|
||||
} else {
|
||||
s.logger.Warn("Email delivery attempt finished", fields...)
|
||||
}
|
||||
if err = s.repo.Finish(finishCtx, n); err != nil {
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
type workerRepo struct {
|
||||
Repository
|
||||
job model.Notification
|
||||
saved *model.Notification
|
||||
finishErr error
|
||||
}
|
||||
|
||||
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
|
||||
func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notification, error) {
|
||||
n := r.job
|
||||
return &n, nil
|
||||
}
|
||||
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error {
|
||||
r.saved = n
|
||||
return r.finishErr
|
||||
}
|
||||
|
||||
type workerSender struct {
|
||||
calls int
|
||||
outcome email.Outcome
|
||||
}
|
||||
|
||||
func (s *workerSender) Send(context.Context, string, model.Email) email.Outcome {
|
||||
s.calls++
|
||||
return s.outcome
|
||||
}
|
||||
func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
expiry time.Time
|
||||
driver string
|
||||
attempts uint32
|
||||
outcome model.State
|
||||
want model.State
|
||||
calls int
|
||||
}{
|
||||
{"expired", time.Now().Add(-time.Minute), "resend", 1, model.Accepted, model.Expired, 0},
|
||||
{"driver changed", time.Now().Add(time.Hour), "sendgrid", 1, model.Accepted, model.Failed, 0},
|
||||
{"retry exhausted", time.Now().Add(time.Hour), "resend", 5, model.RetryWait, model.Failed, 1},
|
||||
{"retry expiry", time.Now().Add(time.Second), "resend", 1, model.RetryWait, model.Expired, 1},
|
||||
{"ambiguous", time.Now().Add(time.Hour), "resend", 1, model.Unknown, model.Unknown, 1},
|
||||
{"invalid driver outcome", time.Now().Add(time.Hour), "resend", 1, model.Queued, model.Unknown, 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := &workerRepo{job: model.Notification{Driver: tc.driver, ExpiresAt: tc.expiry, Attempts: tc.attempts}}
|
||||
sender := &workerSender{outcome: email.Outcome{State: tc.outcome}}
|
||||
svc := New(repo, nil, sender, "resend", zap.NewNop())
|
||||
if _, err := svc.Dispatch(context.Background(), "id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.saved == nil || repo.saved.State != tc.want || sender.calls != tc.calls {
|
||||
t.Fatal(repo.saved, sender.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerLogsProviderFailureOnce(t *testing.T) {
|
||||
for _, failPersistence := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(failPersistence), func(t *testing.T) {
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
repo := &workerRepo{job: model.Notification{Driver: "resend", ExpiresAt: time.Now().Add(time.Hour), Attempts: 1, Email: model.Email{Address: "private@example.com", Subject: "Private subject", Text: "Private text"}}}
|
||||
if failPersistence {
|
||||
repo.finishErr = merrors.ServiceUnavailable("storage offline")
|
||||
}
|
||||
sender := &workerSender{outcome: email.Outcome{State: model.Failed, Code: "provider_authentication_failed", HTTPStatus: 403, ProviderError: "validation_error", ProviderMessage: "The sxchnge.com domain is not verified"}}
|
||||
svc := New(repo, nil, sender, "resend", zap.New(core))
|
||||
if _, err := svc.Dispatch(context.Background(), "id"); !errors.Is(err, repo.finishErr) {
|
||||
t.Fatal("unexpected persistence error", err)
|
||||
}
|
||||
entries := logs.All()
|
||||
if len(entries) != 1 || entries[0].Message != "Email delivery attempt finished" {
|
||||
t.Fatal("expected one provider outcome log", entries)
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
if fields["provider_status"] != int64(403) || fields["provider_error"] != "validation_error" || fields["provider_message"] != "The sxchnge.com domain is not verified" {
|
||||
t.Fatal("provider diagnostic missing", fields)
|
||||
}
|
||||
if repo.saved.FailureCode != "provider_authentication_failed" {
|
||||
t.Fatal("public failure code changed", repo.saved.FailureCode)
|
||||
}
|
||||
for _, field := range []string{"address", "email", "subject", "text", "html", "parameters"} {
|
||||
if _, ok := fields[field]; ok {
|
||||
t.Fatalf("private notification field %s logged", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/source"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
template.DBImp[*model.Notification]
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, db source.Source) (*Store, error) {
|
||||
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
|
||||
ttl := int32(0)
|
||||
for _, index := range []*ri.Definition{
|
||||
{Name: "unique_caller_key", Keys: []ri.Key{{Field: "caller", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, Unique: true},
|
||||
{Name: "pending_dispatch", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "next_attempt_at", Sort: ri.Asc}}},
|
||||
{Name: "pending_expiry", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "expires_at", Sort: ri.Asc}}},
|
||||
{Name: "expired_lease", Keys: []ri.Key{{Field: "state", Sort: ri.Asc}, {Field: "lease_until", Sort: ri.Asc}}},
|
||||
{Name: "purge_notifications", Keys: []ri.Key{{Field: "purge_at", Sort: ri.Asc}}, TTL: &ttl},
|
||||
} {
|
||||
if err := s.Repository.CreateIndex(index); err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func wrap(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrDataConflict) {
|
||||
return err
|
||||
}
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "notification storage unavailable")
|
||||
}
|
||||
func (s *Store) Find(ctx context.Context, caller, key string) (*model.Notification, error) {
|
||||
var n model.Notification
|
||||
err := s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.Filter("key", key)), &n)
|
||||
return &n, wrap(err)
|
||||
}
|
||||
func (s *Store) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
|
||||
oid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid notification ID")
|
||||
}
|
||||
var n model.Notification
|
||||
err = s.FindOne(ctx, repository.Query().And(repository.Filter("caller", caller), repository.IDFilter(oid)), &n)
|
||||
return &n, wrap(err)
|
||||
}
|
||||
func (s *Store) Create(ctx context.Context, n *model.Notification) (*model.Notification, error) {
|
||||
err := s.DBImp.Create(ctx, n)
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return s.Find(ctx, n.Caller, n.Key)
|
||||
}
|
||||
return n, wrap(err)
|
||||
}
|
||||
func pending(now time.Time) builder.Query {
|
||||
return repository.Query().And(repository.Query().In(repository.Field("state"), model.Queued, model.RetryWait), repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now))
|
||||
}
|
||||
|
||||
// Expire pending messages independently of bus availability, then fence lost workers.
|
||||
// A lost worker may already have sent its message. Do not automatically replay it.
|
||||
func (s *Store) Recover(ctx context.Context, now time.Time) error {
|
||||
expired := repository.Query().And(repository.Query().In(repository.Field("state"), model.Queued, model.RetryWait), repository.Query().Comparison(repository.Field("expires_at"), builder.Lte, now))
|
||||
expirePatch := repository.Patch().Set(repository.Field("state"), model.Expired).Set(repository.Field("failure_code"), "expired").Set(repository.Field("updatedAt"), now).Set(repository.Field("email"), model.Email{})
|
||||
count, err := s.PatchMany(ctx, expired, expirePatch)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if count > 0 {
|
||||
s.Logger.Info("Pending notifications expired", zap.Int("count", count))
|
||||
}
|
||||
query := repository.Query().And(repository.Filter("state", model.Sending), repository.Query().Comparison(repository.Field("lease_until"), builder.Lte, now))
|
||||
patch := repository.Patch().Set(repository.Field("state"), model.Unknown).Set(repository.Field("failure_code"), "worker_outcome_unknown").Set(repository.Field("updatedAt"), now).Set(repository.Field("email"), model.Email{})
|
||||
count, err = s.PatchMany(ctx, query, patch)
|
||||
if err == nil && count > 0 {
|
||||
s.Logger.Warn("Notification workers lost their leases", zap.Int("count", count))
|
||||
}
|
||||
return wrap(err)
|
||||
}
|
||||
func (s *Store) Claim(ctx context.Context, id string, now time.Time) (*model.Notification, error) {
|
||||
oid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return nil, merrors.NoData("invalid job ID")
|
||||
}
|
||||
var n model.Notification
|
||||
query := repository.Query().And(repository.IDFilter(oid), pending(now))
|
||||
patch := repository.Patch().Set(repository.Field("state"), model.Sending).Set(repository.Field("updatedAt"), now).Set(repository.Field("lease_until"), now.Add(time.Minute)).Inc(repository.Field("attempts"), 1)
|
||||
matched, err := s.Repository.PatchOneByFilter(ctx, query, patch, &n)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
if !matched {
|
||||
return nil, merrors.NoData("job is not pending")
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
func (s *Store) Finish(ctx context.Context, n *model.Notification) error {
|
||||
query := repository.Query().And(repository.IDFilter(n.ID), repository.Filter("state", model.Sending), repository.Filter("attempts", n.Attempts), repository.Filter("lease_until", n.LeaseUntil))
|
||||
patch := repository.Patch().Set(repository.Field("state"), n.State).Set(repository.Field("failure_code"), n.FailureCode).Set(repository.Field("updatedAt"), n.UpdatedAt).Set(repository.Field("next_attempt_at"), n.NextAttemptAt)
|
||||
if n.State != model.RetryWait {
|
||||
patch.Set(repository.Field("email"), model.Email{})
|
||||
}
|
||||
count, err := s.PatchMany(ctx, query, patch)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if count != 1 {
|
||||
return merrors.DataConflict("delivery lease lost")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Acceptance and outbox work are one document, so no dual-write transaction is needed.
|
||||
func (s *Store) Due(ctx context.Context, now time.Time) ([]model.Notification, error) {
|
||||
limit := int64(100)
|
||||
var jobs []model.Notification
|
||||
err := s.Repository.FindAllByFilter(ctx, pending(now).Sort(repository.Field("next_attempt_at"), true).Limit(&limit), &jobs)
|
||||
return jobs, wrap(err)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"id": "exchange.completed",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"first_name",
|
||||
"amount",
|
||||
"currency"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your exchange is complete",
|
||||
"html": "<p>Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete.</p>",
|
||||
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auth.activation",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"url"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Verify your SXCHNGE email",
|
||||
"text": "Confirm your email: {{.url}}\nThis link expires in 24 hours. If you did not sign up, ignore this email.",
|
||||
"html": "<p>Confirm your email to finish signing up.</p><p><a href=\"{{.url}}\">Verify email</a></p><p>This link expires in 24 hours. If you did not sign up, ignore this email.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Подтвердите почту SXCHNGE",
|
||||
"text": "Подтвердите почту: {{.url}}\nСсылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.",
|
||||
"html": "<p><a href=\"{{.url}}\">Подтвердить почту</a></p><p>Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.</p>"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auth.login",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"code"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your SXCHNGE login code",
|
||||
"text": "Your login code is {{.code}}. It expires in 10 minutes. Never share this code.",
|
||||
"html": "<p>Your login code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Never share this code.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Код входа SXCHNGE",
|
||||
"text": "Ваш код входа: {{.code}}. Он действует 10 минут. Никому не сообщайте код.",
|
||||
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "twofactor.code",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"code"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your SXCHNGE verification code",
|
||||
"text": "Your verification code is {{.code}}. It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.",
|
||||
"html": "<p>Your verification code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Only enter it for the operation you requested. Never share this code.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Код подтверждения SXCHNGE",
|
||||
"text": "Ваш код подтверждения: {{.code}}. Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.",
|
||||
"html": "<p>Ваш код подтверждения: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Вводите его только для запрошенной вами операции. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
html "html/template"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
text "text/template"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type Content struct {
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
type Definition struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
DefaultLocale string `json:"default_locale"`
|
||||
Parameters []string `json:"parameters"`
|
||||
Locales map[string]Content `json:"locales"`
|
||||
}
|
||||
type compiled struct {
|
||||
definition Definition
|
||||
locales map[string]renderers
|
||||
}
|
||||
type renderers struct {
|
||||
subject, text *text.Template
|
||||
html *html.Template
|
||||
}
|
||||
type Catalog struct{ entries map[string]compiled }
|
||||
|
||||
func Load(path string) (*Catalog, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "cannot open template catalog")
|
||||
}
|
||||
defer f.Close()
|
||||
var definitions []Definition
|
||||
dec := json.NewDecoder(io.LimitReader(f, 4<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err = dec.Decode(&definitions); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid template catalog")
|
||||
}
|
||||
if err = dec.Decode(new(any)); err != io.EOF {
|
||||
return nil, merrors.InvalidArgument("unexpected template catalog content")
|
||||
}
|
||||
return New(definitions)
|
||||
}
|
||||
func New(definitions []Definition) (*Catalog, error) {
|
||||
c := &Catalog{entries: map[string]compiled{}}
|
||||
if len(definitions) == 0 {
|
||||
return nil, merrors.InvalidArgument("empty template catalog")
|
||||
}
|
||||
for _, d := range definitions {
|
||||
key := d.ID + "\x00" + d.Version
|
||||
if d.ID == "" || d.Version == "" || strings.ContainsRune(d.ID+d.Version, 0) {
|
||||
return nil, merrors.InvalidArgument("invalid template identity")
|
||||
}
|
||||
if _, ok := c.entries[key]; ok {
|
||||
return nil, merrors.InvalidArgument("duplicate template revision")
|
||||
}
|
||||
if _, ok := d.Locales[d.DefaultLocale]; !ok || d.DefaultLocale == "" {
|
||||
return nil, merrors.InvalidArgument("missing default locale")
|
||||
}
|
||||
params := map[string]string{}
|
||||
for _, p := range d.Parameters {
|
||||
if p == "" {
|
||||
return nil, merrors.InvalidArgument("empty parameter")
|
||||
}
|
||||
if _, ok := params[p]; ok {
|
||||
return nil, merrors.InvalidArgument("duplicate parameter")
|
||||
}
|
||||
params[p] = "sample"
|
||||
}
|
||||
entry := compiled{definition: d, locales: map[string]renderers{}}
|
||||
for locale, content := range d.Locales {
|
||||
s, err := text.New("subject").Option("missingkey=error").Parse(content.Subject)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid subject template")
|
||||
}
|
||||
t, err := text.New("text").Option("missingkey=error").Parse(content.Text)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid text template")
|
||||
}
|
||||
h, err := html.New("html").Option("missingkey=error").Parse(content.HTML)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid HTML template")
|
||||
}
|
||||
entry.locales[locale] = renderers{s, t, h}
|
||||
}
|
||||
c.entries[key] = entry
|
||||
for locale := range d.Locales {
|
||||
if _, err := c.Render(model.Request{TemplateID: d.ID, TemplateVersion: d.Version, Locale: locale, Parameters: params}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type boundedBuffer struct{ bytes.Buffer }
|
||||
|
||||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||||
if b.Len()+len(p) > 256<<10 {
|
||||
return 0, merrors.InvalidArgument("rendered content too large")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
func (c *Catalog) Render(r model.Request) (model.Email, error) {
|
||||
entry, ok := c.entries[r.TemplateID+"\x00"+r.TemplateVersion]
|
||||
if !ok {
|
||||
return model.Email{}, merrors.NoData("template revision not found")
|
||||
}
|
||||
if len(r.Parameters) != len(entry.definition.Parameters) {
|
||||
return model.Email{}, merrors.InvalidArgument("template parameters do not match")
|
||||
}
|
||||
for _, p := range entry.definition.Parameters {
|
||||
if _, ok := r.Parameters[p]; !ok {
|
||||
return model.Email{}, merrors.InvalidArgument("missing template parameter")
|
||||
}
|
||||
}
|
||||
locale := r.Locale
|
||||
if locale == "" {
|
||||
locale = entry.definition.DefaultLocale
|
||||
}
|
||||
render, ok := entry.locales[locale]
|
||||
if !ok {
|
||||
return model.Email{}, merrors.InvalidArgument("unsupported template locale")
|
||||
}
|
||||
var subject, body, plain boundedBuffer
|
||||
for _, run := range []func() error{func() error { return render.subject.Execute(&subject, r.Parameters) }, func() error { return render.html.Execute(&body, r.Parameters) }, func() error { return render.text.Execute(&plain, r.Parameters) }} {
|
||||
if err := run(); err != nil {
|
||||
return model.Email{}, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "template rendering failed")
|
||||
}
|
||||
}
|
||||
if subject.Len() == 0 || subject.Len() > 998 || strings.ContainsAny(subject.String(), "\r\n") || (body.Len() == 0 && plain.Len() == 0) {
|
||||
return model.Email{}, merrors.InvalidArgument("invalid rendered email")
|
||||
}
|
||||
return model.Email{Address: r.Address, Subject: subject.String(), HTML: body.String(), Text: plain.String()}, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func TestRenderAndValidate(t *testing.T) {
|
||||
c, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Parameters: []string{"name"}, Locales: map[string]Content{"en": {Subject: "Hello {{.name}}", HTML: "<p>{{.name}}</p>", Text: "{{.name}}"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := model.Request{TemplateID: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "<script>{{.other}}</script>"}}
|
||||
result, err := c.Render(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(result.HTML, "<script>") || !strings.Contains(result.HTML, "{{.other}}") || result.Text != r.Parameters["name"] {
|
||||
t.Fatalf("unsafe substitution: %+v", result)
|
||||
}
|
||||
for _, params := range []map[string]string{{}, {"name": "a", "extra": "b"}, {"wrong": "a"}, {"name": "bad\r\nsubject"}} {
|
||||
r.Parameters = params
|
||||
if _, err := c.Render(r); err == nil {
|
||||
t.Fatal("invalid parameters accepted")
|
||||
}
|
||||
}
|
||||
r.Parameters = map[string]string{"name": "valid"}
|
||||
r.Locale = "unknown"
|
||||
if _, err := c.Render(r); err == nil {
|
||||
t.Fatal("unknown locale accepted")
|
||||
}
|
||||
}
|
||||
func TestRejectBrokenCatalog(t *testing.T) {
|
||||
for _, content := range []Content{{Subject: "Hello", HTML: "{{.undeclared}}"}, {Subject: "Hello", HTML: "{{"}, {HTML: "body"}} {
|
||||
_, err := New([]Definition{{ID: "test", Version: "1", DefaultLocale: "en", Locales: map[string]Content{"en": content}}})
|
||||
if err == nil {
|
||||
t.Fatal("invalid template accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func request(r *pb.SendRequest) (model.Request, error) {
|
||||
if r == nil || len(r.Targets) != 1 {
|
||||
return model.Request{}, merrors.InvalidArgument("exactly one email target is required")
|
||||
}
|
||||
target := r.Targets[0]
|
||||
if target == nil || target.Destination == nil {
|
||||
return model.Request{}, merrors.InvalidArgument("missing destination")
|
||||
}
|
||||
email, ok := target.Destination.(*pb.Target_Email)
|
||||
if !ok {
|
||||
return model.Request{}, merrors.NotImplemented("channel is not implemented")
|
||||
}
|
||||
if email.Email == nil {
|
||||
return model.Request{}, merrors.InvalidArgument("missing email target")
|
||||
}
|
||||
var expiry time.Time
|
||||
if r.ExpiresAt != nil {
|
||||
if err := r.ExpiresAt.CheckValid(); err != nil {
|
||||
return model.Request{}, merrors.InvalidArgument("invalid expiry")
|
||||
}
|
||||
expiry = r.ExpiresAt.AsTime()
|
||||
}
|
||||
return model.Request{Key: r.IdempotencyKey, UserID: r.UserId, TemplateID: r.TemplateId, TemplateVersion: r.TemplateVersion, Parameters: r.Parameters, Locale: r.Locale, Address: email.Email.Address, ExpiresAt: expiry}, nil
|
||||
}
|
||||
func response(n *model.Notification) *pb.Notification {
|
||||
states := map[model.State]pb.DeliveryState{model.Queued: pb.DeliveryState_DELIVERY_STATE_QUEUED, model.Sending: pb.DeliveryState_DELIVERY_STATE_SENDING, model.RetryWait: pb.DeliveryState_DELIVERY_STATE_RETRY_WAIT, model.Accepted: pb.DeliveryState_DELIVERY_STATE_ACCEPTED, model.Failed: pb.DeliveryState_DELIVERY_STATE_FAILED, model.Expired: pb.DeliveryState_DELIVERY_STATE_EXPIRED, model.Unknown: pb.DeliveryState_DELIVERY_STATE_UNKNOWN}
|
||||
d := &pb.Delivery{Id: n.ID.Hex() + "-email", Channel: pb.Channel_CHANNEL_EMAIL, State: states[n.State], AttemptCount: n.Attempts, UpdatedAt: timestamppb.New(n.UpdatedAt)}
|
||||
if n.FailureCode != "" {
|
||||
d.Failure = &pb.Failure{Code: n.FailureCode, Message: "See the documented delivery failure code."}
|
||||
}
|
||||
if n.State == model.Queued || n.State == model.RetryWait {
|
||||
d.NextAttemptAt = timestamppb.New(n.NextAttemptAt)
|
||||
}
|
||||
return &pb.Notification{Id: n.ID.Hex(), UserId: n.UserID, TemplateId: n.TemplateID, TemplateVersion: n.TemplateVersion, Deliveries: []*pb.Delivery{d}, CreatedAt: timestamppb.New(n.CreatedAt), UpdatedAt: timestamppb.New(n.UpdatedAt), ExpiresAt: timestamppb.New(n.ExpiresAt)}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
Send(context.Context, string, model.Request) (*model.Notification, error)
|
||||
Get(context.Context, string, string) (*model.Notification, error)
|
||||
}
|
||||
type Server struct {
|
||||
pb.UnimplementedNotificationsServiceServer
|
||||
service API
|
||||
}
|
||||
|
||||
// The private endpoint uses the existing product storage namespace without caller authentication.
|
||||
func New(service API) *Server { return &Server{service: service} }
|
||||
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
|
||||
input, err := request(r)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
n, err := s.service.Send(ctx, "product", input)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return &pb.SendResponse{Notification: response(n)}, nil
|
||||
}
|
||||
func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, error) {
|
||||
if r == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing request")
|
||||
}
|
||||
n, err := s.service.Get(ctx, "product", r.Id)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return &pb.GetResponse{Notification: response(n)}, nil
|
||||
}
|
||||
|
||||
func rpcError(err error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return status.Error(codes.Canceled, "notification operation canceled")
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return status.Error(codes.DeadlineExceeded, "notification operation deadline exceeded")
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
err error
|
||||
code codes.Code
|
||||
message string
|
||||
}{
|
||||
{merrors.ErrInvalidArg, codes.InvalidArgument, "invalid notification request"}, {merrors.ErrNoData, codes.NotFound, "notification or template not found"}, {merrors.ErrDataConflict, codes.AlreadyExists, "idempotency key already used"}, {merrors.ErrUnauthorized, codes.Unauthenticated, "missing service credentials"}, {merrors.ErrAccessDenied, codes.PermissionDenied, "access denied"}, {merrors.ErrNotImplemented, codes.Unimplemented, "channel is not implemented"}, {merrors.ErrServiceUnavailable, codes.Unavailable, "notification storage unavailable"},
|
||||
} {
|
||||
if errors.Is(err, pair.err) {
|
||||
return status.Error(pair.code, pair.message)
|
||||
}
|
||||
}
|
||||
return status.Error(codes.Internal, "notification operation failed")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
type fakeAPI struct{}
|
||||
|
||||
func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.Notification, error) {
|
||||
if caller != "product" {
|
||||
return nil, merrors.Unauthorized("missing caller")
|
||||
}
|
||||
return &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: time.Now(), UpdatedAt: time.Now()}, UserID: r.UserID, State: model.Queued, Email: model.Email{Address: r.Address, Text: "sensitive"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
|
||||
return nil, merrors.NoData("private details")
|
||||
}
|
||||
func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
|
||||
listener := bufconn.Listen(1 << 20)
|
||||
server := grpc.NewServer()
|
||||
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer server.Stop()
|
||||
conn, err := grpc.NewClient("passthrough:///buf", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
client := pb.NewNotificationsServiceClient(conn)
|
||||
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
||||
ctx := context.Background()
|
||||
result, err := client.Send(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Notification.UserId != "u" || strings.Contains(result.String(), "sensitive") || strings.Contains(result.String(), "user@example.com") {
|
||||
t.Fatal(result)
|
||||
}
|
||||
req.Targets[0].Destination = &pb.Target_Telegram{Telegram: &pb.TelegramTarget{ChatId: "123"}}
|
||||
if _, err = client.Send(ctx, req); status.Code(err) != codes.Unimplemented {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = client.Get(ctx, &pb.GetRequest{Id: "id"}); status.Code(err) != codes.NotFound {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationStatus(t *testing.T) {
|
||||
if status.Code(rpcError(merrors.OpaqueWrap(merrors.ErrServiceUnavailable, context.Canceled, "storage"))) != codes.Canceled {
|
||||
t.Fatal("cancellation lost at transport boundary")
|
||||
}
|
||||
if status.Code(rpcError(context.DeadlineExceeded)) != codes.DeadlineExceeded {
|
||||
t.Fatal("deadline lost at transport boundary")
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,29 @@ organization, role, policy or seed; the runtime creates its indexes.
|
||||
The runtime mounts health, login, API login, refresh, rotation and logout routes.
|
||||
Customer/application-client records must exist for login. The customer service
|
||||
supports atomic account/activation creation and deletion with session revocation.
|
||||
Signup/profile HTTP wiring and verification delivery/completion remain follow-up
|
||||
work. The frontend still uses dummy services.
|
||||
Signup and email verification delivery/completion are available when
|
||||
`api.notifications` is configured. Flutter selects the backend using
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||
API versions are installed explicitly by the product composition. See
|
||||
[version installation and compatibility](../../docs/api-versions.md) for selection,
|
||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||
wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
copy only the wire definitions consumed by an implemented adapter.
|
||||
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||
contracts; local customers retain no platform tenancy. See
|
||||
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||
|
||||
## Validate
|
||||
|
||||
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
|
||||
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
|
||||
validate `shared`. See [source provenance](../PROVENANCE.md).
|
||||
|
||||
## Customer quotations and payments
|
||||
|
||||
With `api.sendico`, runtime composes the customer-scoped payment service and
|
||||
public HTTP provider adapter. Flutter backend mode selects this path automatically;
|
||||
missing provider configuration never returns demo money. See
|
||||
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
|
||||
@@ -1,23 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
LifecycleContext() context.Context
|
||||
Logger() mlogger.Logger
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Config() *Config
|
||||
ProfileConfig() ConfigProvider
|
||||
DBFactory() db.Factory
|
||||
RuntimeComponent(name string) any
|
||||
Register() Register
|
||||
// Dependencies are initialized once and shared by installed transports.
|
||||
type Dependencies struct {
|
||||
Stores *storage.Stores
|
||||
Customers accountservice.AccountService
|
||||
Auth *flow.Service
|
||||
Factors *twofactor.Service
|
||||
Identity *identity.Service
|
||||
Payments *payments.Service
|
||||
Token middleware.TokenConfig
|
||||
Signature middleware.Signature
|
||||
LoginLimits middleware.LoginRateLimits
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
// Scope belongs to one installation. Router paths are relative to Mount.
|
||||
type Scope struct {
|
||||
Router chi.Router
|
||||
Mount string
|
||||
URLs domainprovider.DomainProvider
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
type Installer func(Scope, *Dependencies) error
|
||||
|
||||
type Mount struct {
|
||||
Version string `yaml:"version"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
@@ -2,13 +2,19 @@ package api
|
||||
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
Sendico *sendico.Config `yaml:"sendico"`
|
||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||
Versions []Mount `yaml:"versions"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
}
|
||||
|
||||
func (c *Config) SharedConfig() *Config {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Register interface {
|
||||
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
}
|
||||
@@ -1,36 +1,32 @@
|
||||
package api
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/shared/service"
|
||||
import (
|
||||
"context"
|
||||
|
||||
type ConfigProvider interface {
|
||||
SharedConfig() *Config
|
||||
}
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type ConfigProvider interface{ SharedConfig() *Config }
|
||||
type ConfigFactory func() ConfigProvider
|
||||
type RuntimeComponents map[string]any
|
||||
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
|
||||
type RuntimeFinishFunc func(API) error
|
||||
|
||||
// Bootstrap opens process-owned resources. Its cleanup is called once even if
|
||||
// installation fails. On error, bootstrap must return cleanup or clean its own
|
||||
// resources before returning, never both.
|
||||
type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error)
|
||||
type RuntimeConfig struct {
|
||||
Name string
|
||||
Services []ServiceFactory
|
||||
ConfigFactory ConfigFactory
|
||||
Bootstrap RuntimeBootstrapFunc
|
||||
Finish RuntimeFinishFunc
|
||||
}
|
||||
|
||||
type ServiceFactory struct {
|
||||
Name mservice.Type
|
||||
Factory MicroServiceFactoryT
|
||||
Bootstrap Bootstrap
|
||||
Versions map[string]Installer
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) RuntimeName() string {
|
||||
if cfg.Name == "" {
|
||||
return "edge"
|
||||
return "product"
|
||||
}
|
||||
return cfg.Name
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
||||
if cfg.ConfigFactory != nil {
|
||||
return cfg.ConfigFactory()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package v1 composes the product's first public HTTP contract.
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
|
||||
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
|
||||
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func Install(scope api.Scope, deps *api.Dependencies) error {
|
||||
if deps == nil || deps.Stores == nil {
|
||||
return merrors.InvalidArgument("v1 auth dependencies are required")
|
||||
}
|
||||
flows := public.Flows{Onboarding: deps.Auth}
|
||||
if deps.Factors != nil {
|
||||
flows.Factors = deps.Factors
|
||||
}
|
||||
// Signature is resolved at bootstrap, not by each version from the environment.
|
||||
routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows)
|
||||
if deps.Factors != nil {
|
||||
factors.Install(routes, deps.Factors, scope.Logger)
|
||||
}
|
||||
identity.Install(routes, deps.Identity, scope.Logger)
|
||||
paymenthttp.Install(routes, deps.Payments, scope.Logger)
|
||||
return nil
|
||||
}
|
||||
@@ -2,18 +2,12 @@ package httptransport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
@@ -26,29 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
mw.epdispatcher.Handler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
}
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
mw.metrics.Finish()
|
||||
if mw.metrics != nil {
|
||||
mw.metrics.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
mw.logger.Debug("Installing middleware stack...")
|
||||
// Collect metrics for application traffic only. Scrapes and health probes are
|
||||
@@ -100,14 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||
}
|
||||
|
||||
func CreateMiddleware(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
apiRouter *chi.Mux,
|
||||
config *middleware.Config,
|
||||
authStorage *middleware.AuthStorageConfig,
|
||||
debug bool,
|
||||
) (*Middleware, error) {
|
||||
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
}
|
||||
@@ -117,37 +92,12 @@ func CreateMiddleware(
|
||||
if err := config.CORS.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRateLimits, err := config.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
logger.Error("Invalid login rate limit configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p := &Middleware{
|
||||
logger: logger.Named("middleware"),
|
||||
router: apiRouter,
|
||||
apiEndpoint: os.Getenv(config.EndPointEnv),
|
||||
}
|
||||
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
|
||||
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
|
||||
p.installMiddleware(config, debug)
|
||||
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
||||
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
||||
var err error
|
||||
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
stores.Accounts,
|
||||
stores.Verification,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
)
|
||||
return p, nil
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
// Package profile selects the locally owned components of the copied BFF.
|
||||
// Package profile selects the product's supported API implementations.
|
||||
package profile
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/app/api/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
)
|
||||
|
||||
const Accounts = "accounts"
|
||||
|
||||
func Runtime() api.RuntimeConfig {
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
|
||||
}
|
||||
|
||||
func bootstrap(a api.API) (api.RuntimeComponents, error) {
|
||||
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.RuntimeComponents{Accounts: accounts}, nil
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
|
||||
}
|
||||
@@ -2,68 +2,20 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Microservices = []mservice.MicroService
|
||||
|
||||
// APIImp represents the structure of the APIImp
|
||||
type APIImp struct {
|
||||
lifecycleCtx context.Context
|
||||
logger mlogger.Logger
|
||||
db db.Factory
|
||||
domain domainprovider.DomainProvider
|
||||
config *api.Config
|
||||
profile api.ConfigProvider
|
||||
runtime api.RuntimeConfig
|
||||
components api.RuntimeComponents
|
||||
services Microservices
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
type instance struct {
|
||||
logger mlogger.Logger
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
cleanup func(context.Context) error
|
||||
once sync.Once
|
||||
finishErr error
|
||||
}
|
||||
|
||||
func (a *APIImp) LifecycleContext() context.Context {
|
||||
return a.lifecycleCtx
|
||||
}
|
||||
|
||||
func (a *APIImp) Logger() mlogger.Logger {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
func (a *APIImp) Config() *api.Config {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func (a *APIImp) ProfileConfig() api.ConfigProvider {
|
||||
return a.profile
|
||||
}
|
||||
|
||||
func (a *APIImp) DBFactory() db.Factory {
|
||||
return a.db
|
||||
}
|
||||
|
||||
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
|
||||
return a.domain
|
||||
}
|
||||
|
||||
func (a *APIImp) Register() api.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) RuntimeComponent(name string) any {
|
||||
if a == nil || a.components == nil {
|
||||
return nil
|
||||
}
|
||||
return a.components[name]
|
||||
}
|
||||
|
||||
func (a *APIImp) Name() string {
|
||||
return "api"
|
||||
}
|
||||
func (a *instance) Name() string { return "api" }
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -16,92 +19,67 @@ import (
|
||||
)
|
||||
|
||||
func CreateAPI(
|
||||
lifecycleCtx context.Context,
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
config *api.Config,
|
||||
profileConfig api.ConfigProvider,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
rootRouter, apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
runtime api.RuntimeConfig,
|
||||
) (mservice.MicroService, error) {
|
||||
p := &APIImp{
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
logger: logger.Named("api"),
|
||||
config: config,
|
||||
profile: profileConfig,
|
||||
db: db,
|
||||
runtime: runtime,
|
||||
) (_ mservice.MicroService, result error) {
|
||||
if config == nil || config.Mw == nil {
|
||||
return nil, merrors.InvalidArgument("API middleware configuration is required")
|
||||
}
|
||||
|
||||
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
||||
endpoint := os.Getenv(config.Mw.EndPointEnv)
|
||||
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
||||
return nil, err
|
||||
if runtime.Bootstrap == nil {
|
||||
return nil, merrors.InvalidArgument("runtime bootstrap is required")
|
||||
}
|
||||
|
||||
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
|
||||
if err := p.installServices(); err != nil {
|
||||
p.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installRuntimeBasics(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
) error {
|
||||
var err error
|
||||
if a.domain, err = domainprovider.CreateDomainProvider(
|
||||
a.logger,
|
||||
a.config.Mw.DomainEnv,
|
||||
a.config.Mw.APIProtocolEnv,
|
||||
a.config.Mw.EndPointEnv,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to initialize domain provider")
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Domain provider installed")
|
||||
|
||||
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
|
||||
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
|
||||
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
|
||||
return err
|
||||
}
|
||||
if a.mw, err = httptransport.CreateMiddleware(
|
||||
logger,
|
||||
db,
|
||||
apiRouter,
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
debug,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
|
||||
if a.runtime.Bootstrap != nil {
|
||||
components, err := a.runtime.Bootstrap(a)
|
||||
// Resolve every URL before opening resources or publishing routes.
|
||||
scopes := make([]api.Scope, len(mounts))
|
||||
for i, mount := range mounts {
|
||||
urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
a.components = components
|
||||
a.logger.Info("Runtime profile bootstrap complete",
|
||||
zap.String("runtime", a.runtime.RuntimeName()),
|
||||
zap.Int("components", len(components)))
|
||||
scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))}
|
||||
scopes[i].Router.Use(browser.Scope(mount.Path))
|
||||
}
|
||||
return nil
|
||||
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := a.Finish(context.Background()); err != nil {
|
||||
a.logger.Warn("Failed startup resource cleanup", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
|
||||
a.cleanup = cleanup
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, mount := range mounts {
|
||||
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
|
||||
}
|
||||
}
|
||||
// Attach only after every installer succeeds.
|
||||
for i, mount := range mounts {
|
||||
apiRouter.Mount(mount.Path, scopes[i].Router)
|
||||
scopes[i].Logger.Info("API version installed")
|
||||
}
|
||||
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return a, nil
|
||||
}
|
||||
@@ -2,18 +2,22 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
@@ -24,7 +28,7 @@ import (
|
||||
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
@@ -48,8 +52,9 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
||||
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
||||
root.Mount("/", routes)
|
||||
@@ -60,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
result = httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
||||
// Enabling the adapter validates credentials, composes once, and does not
|
||||
// register any provider resource until a customer starts sign-up.
|
||||
const source = "507f1f77bcf86cd799439011"
|
||||
claims, err := json.Marshal(map[string]any{"id": source, "exp": time.Now().Add(time.Hour).Unix()})
|
||||
require.NoError(t, err)
|
||||
file := filepath.Join(t.TempDir(), "credential")
|
||||
require.NoError(t, os.WriteFile(file, []byte("header."+base64.RawURLEncoding.EncodeToString(claims)+".signature"), 0600))
|
||||
example.API.Sendico = &sendico.Config{APIURL: "https://api.example/api/v1", OrganizationID: source, SourceID: source, TokenFile: file}
|
||||
dependencies, finish, err := sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dependencies.Auth.Facts)
|
||||
require.NoError(t, finish(ctx))
|
||||
example.API.Sendico.TokenFile = ""
|
||||
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.Error(t, err)
|
||||
example.API.Notifications = nil
|
||||
_, _, err = sharedruntime.OpenDependencies(ctx, zap.NewNop(), &example.API, factory)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -4,28 +4,20 @@ import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) Finish(ctx context.Context) error {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
a.mw.Finish()
|
||||
var lastError error
|
||||
for i := len(a.services) - 1; i >= 0; i-- {
|
||||
if err := (a.services[i]).Finish(ctx); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing service",
|
||||
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
|
||||
} else {
|
||||
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
|
||||
func (a *instance) Finish(ctx context.Context) error {
|
||||
a.once.Do(func() {
|
||||
if a.health != nil {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
}
|
||||
}
|
||||
if a.runtime.Finish != nil {
|
||||
if err := a.runtime.Finish(a); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
|
||||
if a.cleanup != nil {
|
||||
a.finishErr = a.cleanup(ctx)
|
||||
}
|
||||
}
|
||||
return lastError
|
||||
if a.mw != nil {
|
||||
a.mw.Finish()
|
||||
}
|
||||
})
|
||||
return a.finishErr
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func validMount(value string) bool {
|
||||
if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, c := range value {
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func overlaps(a, b string) bool {
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) {
|
||||
if !validMount(endpoint) {
|
||||
return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint")
|
||||
}
|
||||
if configured == nil {
|
||||
configured = []api.Mount{{Version: "v1", Path: endpoint}}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, mount := range configured {
|
||||
if supported[mount.Version] == nil {
|
||||
return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
if seen[mount.Version] {
|
||||
return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
seen[mount.Version] = true
|
||||
if !validMount(mount.Path) {
|
||||
return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions")
|
||||
}
|
||||
for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} {
|
||||
if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") {
|
||||
return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions")
|
||||
}
|
||||
}
|
||||
for _, previous := range configured[:i] {
|
||||
if overlaps(mount.Path, previous.Path) {
|
||||
return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions")
|
||||
}
|
||||
}
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *api.Config {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../config.example.yml")
|
||||
require.NoError(t, err)
|
||||
var raw struct {
|
||||
API api.Config `yaml:"api"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &raw))
|
||||
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
|
||||
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
|
||||
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
|
||||
return &raw.API
|
||||
}
|
||||
|
||||
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
|
||||
initialized, closed := 0, 0
|
||||
deps := &api.Dependencies{}
|
||||
installer := func(version string) api.Installer {
|
||||
return func(scope api.Scope, got *api.Dependencies) error {
|
||||
require.Same(t, deps, got)
|
||||
link, err := scope.URLs.GetAPILink("accounts")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
|
||||
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
|
||||
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
|
||||
})
|
||||
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
initialized++
|
||||
return deps, func(context.Context) error { closed++; return nil }, nil
|
||||
}}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
|
||||
require.NoError(t, err)
|
||||
root.Mount("/", routes)
|
||||
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
require.Equal(t, expected, result.Body.String())
|
||||
}
|
||||
t.Setenv("API_ENDPOINT", "/wrong/global")
|
||||
for _, mount := range config.Versions {
|
||||
for _, method := range []string{http.MethodPost, http.MethodDelete} {
|
||||
result := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
|
||||
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
|
||||
root.ServeHTTP(result, request)
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
cookies := result.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
|
||||
if method == http.MethodDelete {
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
require.Equal(t, 1, initialized)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 3)
|
||||
for range 3 {
|
||||
wg.Go(func() { results <- srv.Finish(context.Background()) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
|
||||
for name, mounts := range map[string][]api.Mount{
|
||||
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
|
||||
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
|
||||
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
|
||||
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
|
||||
"control": {{Version: "v1", Path: "/api/\x01"}},
|
||||
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
|
||||
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
|
||||
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = mounts
|
||||
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
t.Fatal("bootstrap must not run")
|
||||
return nil, nil, nil
|
||||
}})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
|
||||
closed := 0
|
||||
cause := merrors.InvalidArgument("test installation failure")
|
||||
routes := chi.NewRouter()
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
|
||||
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
|
||||
},
|
||||
Versions: map[string]api.Installer{
|
||||
"v1": func(scope api.Scope, _ *api.Dependencies) error {
|
||||
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
return nil
|
||||
},
|
||||
"v2": func(api.Scope, *api.Dependencies) error { return cause },
|
||||
}})
|
||||
require.True(t, errors.Is(err, cause))
|
||||
require.Equal(t, 1, closed)
|
||||
result := httptest.NewRecorder()
|
||||
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
}
|
||||
|
||||
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, nil, nil
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
defer srv.Finish(context.Background())
|
||||
root.Mount("/", routes)
|
||||
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, status, result.Code, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
closed := 0
|
||||
cause := merrors.Internal("bootstrap failure")
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return nil, func(context.Context) error { closed++; return nil }, cause
|
||||
}})
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
|
||||
cause := merrors.Internal("cleanup failure")
|
||||
closed := 0
|
||||
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
@@ -1,39 +1,98 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||
a.services = append(a.services, srv)
|
||||
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
||||
}
|
||||
|
||||
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
||||
srv, err := srvf(a)
|
||||
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
|
||||
limits, err := config.Mw.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
a.installMicroservice(srv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installServices() error {
|
||||
for _, service := range a.runtime.Services {
|
||||
if service.Factory == nil {
|
||||
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
||||
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits}
|
||||
var client *notifications.Client
|
||||
cleanup := func(context.Context) error {
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
if err := a.addMicroservice(service.Factory); err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := cleanup(context.Background()); err != nil {
|
||||
logger.Warn("Partial bootstrap cleanup failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if c := config.Notifications; c != nil {
|
||||
deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err := deps.Auth.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Auth.Sender = client
|
||||
if config.Sendico != nil {
|
||||
provider, err := sendico.Open(*config.Sendico)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bindings, err := identity.OpenStore(db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
|
||||
deps.Auth.Facts = deps.Identity
|
||||
paymentStore, err := payments.OpenStore(db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||
deps.Auth.Wallet = deps.Payments
|
||||
logger.Info("Sendico sign-up facts integration enabled")
|
||||
}
|
||||
records, err := factorstore.Open(logger, db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return nil
|
||||
if deps.Payments != nil {
|
||||
deps.Payments.Factors = deps.Factors
|
||||
}
|
||||
if config.Sendico != nil && deps.Auth == nil {
|
||||
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
|
||||
}
|
||||
logger.Info("Product dependencies initialized")
|
||||
return deps, cleanup, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, setting string
|
||||
wantNil bool
|
||||
wantLength int
|
||||
}{
|
||||
{name: "omitted", wantNil: true},
|
||||
{name: "disabled", setting: " versions: []\n"},
|
||||
{name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
instance := &Imp{runtime: api.RuntimeConfig{}}
|
||||
configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1)
|
||||
if err := instance.decodeConfig([]byte(configured)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := instance.config.API.SharedConfig().Versions
|
||||
if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength {
|
||||
t.Fatalf("unexpected versions: %#v", versions)
|
||||
}
|
||||
if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") {
|
||||
t.Fatalf("lost version configuration: %#v", versions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (i *Imp) Start() error {
|
||||
|
||||
rootRouter := chi.NewRouter()
|
||||
apiRouter := chi.NewRouter()
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
i.db.CloseConnection()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type DomainProvider interface {
|
||||
GetAPILink(linkElem ...string) (string, error)
|
||||
}
|
||||
|
||||
// CreateDomainProvider accepts a resolved host, protocol and installation mount.
|
||||
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
|
||||
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package domainproviderimp
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
|
||||
return url.JoinPath(dp.apiEndpoint, linkElem...)
|
||||
}
|
||||
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) {
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) {
|
||||
if protocol != "http" && protocol != "https" {
|
||||
return nil, merrors.InvalidArgument("API protocol must be http or https")
|
||||
}
|
||||
parsed, err := url.Parse(protocol + "://" + domain)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin")
|
||||
}
|
||||
if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, merrors.InvalidArgument("API domain must be a host with optional port")
|
||||
}
|
||||
p := new(DomainProviderImp)
|
||||
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
|
||||
var err error
|
||||
apiPath := os.Getenv(apiPointEnv)
|
||||
p.domain = protocol + "://" + domain
|
||||
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
|
||||
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package domainproviderimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestExplicitOriginAndMount(t *testing.T) {
|
||||
provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2")
|
||||
require.NoError(t, err)
|
||||
link, err := provider.GetAPILink("accounts", "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link)
|
||||
link, err = provider.GetFullLink("signup")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/signup", link)
|
||||
}
|
||||
func TestInvalidOrigin(t *testing.T) {
|
||||
for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} {
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1")
|
||||
require.Error(t, err, domain)
|
||||
}
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Provider access is HTTP only. Local Mongo repositories belong to SXCHNGE.
|
||||
// SXCHNGE-owned gRPC and NATS are allowed. Provider access is HTTP only. Local Mongo repositories belong to SXCHNGE.
|
||||
func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) {
|
||||
err := filepath.WalkDir("../..", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -31,7 +31,7 @@ func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) {
|
||||
if strings.HasPrefix(filepath.ToSlash(path), "../../shared/") && strings.HasPrefix(name, "git.sendico.io/tech/sxchnge/") && !strings.HasPrefix(name, "git.sendico.io/tech/sxchnge/shared/") {
|
||||
t.Errorf("shared library imports an executable module: %s in %s", name, path)
|
||||
}
|
||||
for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/", "github.com/casbin/", "/pkg/auth", "/storage/organization", "/mongo/policiesdb", "/mongo/rolesdb", "/mongo/invitationdb"} {
|
||||
for _, blocked := range []string{"github.com/tech/sendico/", "/pkg/discovery", "/pkg/proto/", "github.com/casbin/", "/pkg/auth", "/storage/organization", "/mongo/policiesdb", "/mongo/rolesdb", "/mongo/invitationdb"} {
|
||||
if strings.Contains(name, blocked) {
|
||||
t.Errorf("%s imports private provider transport or shared runtime: %s", path, name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package architecture
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Keep the reusable domain independent of transport and session ownership.
|
||||
func TestSecondFactorDomainBoundary(t *testing.T) {
|
||||
err := filepath.WalkDir("../twofactor", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == "http" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range file.Imports {
|
||||
name, err := strconv.Unquote(im.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, blocked := range []string{"/product/auth/", "/product/sessions/", "/jwtauth", "/jwt/"} {
|
||||
if strings.Contains(name, blocked) {
|
||||
t.Errorf("2FA domain imports operation/transport dependency %s in %s", name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package architecture
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
|
||||
for _, dir := range []string{"../app/runtime", "../app/http"} {
|
||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range file.Imports {
|
||||
name, err := strconv.Unquote(im.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
|
||||
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Package flow owns signup and account activation, independently of second-factor checks.
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const Cooldown = time.Minute
|
||||
|
||||
type SignupFacts interface {
|
||||
Ensure(context.Context, *customer.Account) error
|
||||
Ready(context.Context, bson.ObjectID) error
|
||||
}
|
||||
|
||||
type CurrencyAccounts interface {
|
||||
Ensure(context.Context, *customer.Account) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Facts SignupFacts
|
||||
Wallet CurrencyAccounts
|
||||
Accounts account.DB
|
||||
Customers accountservice.AccountService
|
||||
Tokens verification.DB
|
||||
Transactions transaction.Factory
|
||||
Attempts ratelimits.Store
|
||||
Sender notifications.Sender
|
||||
Secret []byte
|
||||
ActivationURL string
|
||||
}
|
||||
|
||||
func (s *Service) Validate() error {
|
||||
u, err := url.Parse(s.ActivationURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
|
||||
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
|
||||
}
|
||||
if len(s.Secret) < 32 {
|
||||
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) key(parts ...string) string {
|
||||
h := hmac.New(sha256.New, s.Secret)
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
func (s *Service) Limit(ctx context.Context, ip, email string) error {
|
||||
now := time.Now().UTC()
|
||||
start := now.Truncate(time.Hour)
|
||||
for _, scope := range []struct {
|
||||
kind, value string
|
||||
limit int
|
||||
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
|
||||
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
|
||||
if err != nil {
|
||||
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
|
||||
}
|
||||
if count > scope.limit {
|
||||
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
||||
email, err := customer.NormalizeEmail(a.Login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Login = email
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
if len(a.Name) > 200 || len(a.Password) > 72 {
|
||||
return nil, merrors.InvalidArgument("name or password too long")
|
||||
}
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||
if lookup != nil {
|
||||
return nil, lookup
|
||||
}
|
||||
// A retry after a queue failure may resume only with the original password.
|
||||
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
||||
return nil, merrors.DataConflict("email already registered")
|
||||
}
|
||||
a = existing
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.Wallet != nil {
|
||||
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if raw != "" {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", raw)
|
||||
u.RawQuery = q.Encode()
|
||||
locale := "en"
|
||||
if strings.HasPrefix(a.Locale, "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
|
||||
} else {
|
||||
_, err = s.sendActivation(ctx, a, key)
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
||||
email, err := customer.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.Accounts.GetByEmail(ctx, email)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ensure(ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.Wallet != nil {
|
||||
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.sendActivation(ctx, a, key)
|
||||
return err
|
||||
}
|
||||
func (s *Service) activationKey(email, key string) string {
|
||||
return s.key(email, string(verificationmodel.PurposeAccountActivation), "", key)
|
||||
}
|
||||
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||
}
|
||||
secretKey := s.activationKey(a.Login, key)
|
||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if !result.ExpiresAt.After(time.Now()) {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", result.VerificationToken)
|
||||
u.RawQuery = q.Encode()
|
||||
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
||||
return result.ExpiresAt, err
|
||||
}
|
||||
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
|
||||
if len(raw) != 43 {
|
||||
return "", verification.ErorrTokenNotFound()
|
||||
}
|
||||
var email string
|
||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil, merrors.Unauthorized("account cannot be activated")
|
||||
}
|
||||
if s.Facts != nil {
|
||||
if err := s.Facts.Ready(tx, a.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = a.Login
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenStub struct {
|
||||
verification.DB
|
||||
request *verification.Request
|
||||
result verification.CreateResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tokenStub) CreateWithResult(_ context.Context, r *verification.Request) (verification.CreateResult, error) {
|
||||
s.request = r
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
type senderStub struct {
|
||||
messages []notifications.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *senderStub) Send(_ context.Context, m notifications.Message) error {
|
||||
s.messages = append(s.messages, m)
|
||||
return s.err
|
||||
}
|
||||
func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
||||
expiry := time.Now().UTC().Add(time.Minute)
|
||||
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
|
||||
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
|
||||
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Login = "user@example.com"
|
||||
a.Locale = "ru-RU"
|
||||
_, err := service.sendActivation(context.Background(), a, "client-key")
|
||||
require.Error(t, err)
|
||||
key := *tokens.request.IdempotencyKey
|
||||
require.NotEqual(t, "client-key", key)
|
||||
require.Equal(t, verification.TokenKindLink, tokens.request.Kind)
|
||||
require.Equal(t, 24*time.Hour, tokens.request.Ttl)
|
||||
sender.err = nil
|
||||
_, err = service.sendActivation(context.Background(), a, "client-key")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
service.Secret = []byte(strings.Repeat("b", 32))
|
||||
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
|
||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
require.Len(t, sender.messages, 2)
|
||||
}
|
||||
func TestValidateActivationDestination(t *testing.T) {
|
||||
for _, destination := range []string{"https://app.example/verify-email", "http://localhost:5126/verify-email"} {
|
||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
||||
require.NoError(t, s.Validate())
|
||||
}
|
||||
for _, destination := range []string{"http://app.example/verify-email", "//app.example", "https://user:pass@app.example/verify-email", "https://app.example/#/verify"} {
|
||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
||||
require.Error(t, s.Validate())
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
||||
accounts.customer.Status = status
|
||||
routes := chi.NewRouter()
|
||||
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
|
||||
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
called := false
|
||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
called = true
|
||||
+1
-1
@@ -6,9 +6,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
|
||||
service mservice.Type
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
func Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||
router.Use(jwtauth.Verifier(ja))
|
||||
router.Use(jwtauth.Authenticator(ja))
|
||||
File renamed without changes.
+11
-15
@@ -1,21 +1,17 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
@@ -36,28 +32,28 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
|
||||
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewDispatcher(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
endpoint string,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
token *middleware.TokenConfig,
|
||||
signature middleware.Signature,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...rpublic.Flows,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing endpoints middleware...")
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -33,7 +33,7 @@ func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
|
||||
return path.Join(er.apiEndpoint, service, handler)
|
||||
return path.Join("/", er.apiEndpoint, service, handler)
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
+1
-1
@@ -5,8 +5,8 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFactsAssistanceHasTypedPrivacySafeResponse(t *testing.T) {
|
||||
router := &PublicRouter{logger: zap.NewNop(), service: "accounts"}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/signup", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.flowError(merrors.FailedPrecondition("private upstream detail"))(response, request)
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||
require.Equal(t, "no-store", response.Header().Get("Cache-Control"))
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &envelope))
|
||||
require.Equal(t, "signup_attention_required", envelope.Data.Error)
|
||||
require.NotContains(t, response.Body.String(), "private upstream detail")
|
||||
}
|
||||
+8
-5
@@ -5,15 +5,15 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
clientauth "git.sendico.io/tech/sxchnge/product/auth/client"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
@@ -195,7 +195,7 @@ func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.
|
||||
return nil, response.BadPayload(logger, mservice.Accounts, err)
|
||||
}
|
||||
req.Login = strings.TrimSpace(req.Login)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
// Preserve passwords exactly as entered.
|
||||
req.ClientID = strings.TrimSpace(req.ClientID)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
||||
@@ -225,6 +225,9 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
||||
if h = a.validateClientAccountBinding(client, account); h != nil {
|
||||
return h
|
||||
}
|
||||
if a.factors != nil {
|
||||
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
||||
}
|
||||
return a.respondPendingLogin(account)
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+2
-3
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -183,11 +183,10 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
||||
PrivateKey: []byte("test-secret"),
|
||||
}
|
||||
|
||||
router := NewRouter(
|
||||
router := Install(
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
File renamed without changes.
@@ -0,0 +1,35 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var flowRequests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_auth_flow_requests_total", Help: "Authentication flow outcomes; success means this HTTP step completed, not email delivery."}, []string{"operation", "outcome"})
|
||||
var flowDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{Name: "sxchnge_auth_flow_duration_seconds", Help: "Authentication HTTP step latency."}, []string{"operation"})
|
||||
|
||||
func measured(operation string, handler sresponse.HandlerFunc) sresponse.HandlerFunc {
|
||||
return func(r *http.Request) http.HandlerFunc {
|
||||
start := time.Now()
|
||||
response := handler(r)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
response(wrapped, r)
|
||||
outcome := "accepted"
|
||||
if wrapped.Status() >= 500 {
|
||||
outcome = "unavailable"
|
||||
} else if wrapped.Status() == 429 {
|
||||
outcome = "limited"
|
||||
} else if wrapped.Status() >= 400 {
|
||||
outcome = "rejected"
|
||||
}
|
||||
flowRequests.WithLabelValues(operation, outcome).Inc()
|
||||
flowDuration.WithLabelValues(operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
+1
-2
@@ -47,11 +47,10 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
||||
Algorithm: "HS256",
|
||||
PrivateKey: []byte("browser-refresh-test-secret"),
|
||||
}
|
||||
return NewRouter(
|
||||
return Install(
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
+12
-4
@@ -6,7 +6,7 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
@@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
session *sessionmodel.SessionIdentifier,
|
||||
account *customermodel.Account,
|
||||
) http.HandlerFunc {
|
||||
result, err := pr.prepareLogin(ctx, r, session, account)
|
||||
if err != nil {
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
|
||||
refreshToken, err := rtokens.PrepareRefreshToken(
|
||||
ctx,
|
||||
r,
|
||||
@@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
return nil, err
|
||||
}
|
||||
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := sresponse.TokenData{
|
||||
Token: refreshToken.RefreshToken,
|
||||
Expiration: refreshToken.ExpiresAt,
|
||||
}
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token)
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token), nil
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+27
-6
@@ -4,11 +4,12 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
@@ -16,7 +17,14 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Flows struct {
|
||||
Onboarding *flow.Service
|
||||
Factors twofactor.API
|
||||
}
|
||||
|
||||
type PublicRouter struct {
|
||||
factors twofactor.API
|
||||
flow *flow.Service
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
imp *re.HttpEndpointRouter
|
||||
@@ -33,17 +41,17 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
|
||||
pr.imp.InstallHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
router chi.Router,
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
flows ...Flows,
|
||||
) *PublicRouter {
|
||||
l := logger.Named("public")
|
||||
hr := PublicRouter{
|
||||
@@ -59,7 +67,20 @@ func NewRouter(
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
||||
if len(flows) > 0 {
|
||||
hr.flow = flows[0].Onboarding
|
||||
hr.factors = flows[0].Factors
|
||||
}
|
||||
if hr.flow != nil {
|
||||
hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup))
|
||||
hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation))
|
||||
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
|
||||
}
|
||||
if hr.factors != nil {
|
||||
hr.InstallHandler(hr.service, "/login/resend", api.Post, measured("login_send", hr.resendLogin))
|
||||
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
|
||||
}
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, measured("login", hr.login))
|
||||
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
|
||||
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
|
||||
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
@@ -0,0 +1,265 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type signupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Locale string `json:"locale"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
type challengeRequest struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
PendingToken string `json:"pendingToken"`
|
||||
Code string `json:"code"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
type challengeResponse struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
Channel factor.Channel `json:"channel"`
|
||||
UserID string `json:"userId"`
|
||||
PendingToken sresponse.TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ResendAt time.Time `json:"resendAt"`
|
||||
}
|
||||
|
||||
func decodeFlow(r *http.Request, dst any) error {
|
||||
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(dst); err != nil {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func requestIP(r *http.Request) string {
|
||||
if ip := ipguard.ClientIP(r); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return "unresolved"
|
||||
}
|
||||
func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
||||
if details, ok := verification.Throttle(err); ok {
|
||||
return pr.loginRateLimited(details.RetryAfter)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrFailedPrecondition):
|
||||
return response.Error(pr.logger, pr.service, http.StatusServiceUnavailable, "signup_attention_required", "We could not finish setting up your account. Please contact support.")
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code")
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered")
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields")
|
||||
case errors.Is(err, merrors.ErrUnauthorized):
|
||||
return response.Unauthorized(pr.logger, pr.service, "credentials required")
|
||||
default:
|
||||
// Do not include downstream errors: they may contain transport/request details.
|
||||
pr.logger.Warn("Authentication flow dependency failed")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc {
|
||||
var req signupRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
a := &customer.Account{Password: req.Password}
|
||||
a.Name = req.Name
|
||||
a.Login = req.Email
|
||||
a.Locale = req.Locale
|
||||
a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()})
|
||||
}
|
||||
func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, map[string]bool{"accepted": true})
|
||||
}
|
||||
func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
email, err := pr.flow.Activate(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Account activation completed")
|
||||
return response.Accepted(pr.logger, struct {
|
||||
Verified bool `json:"verified"`
|
||||
Email string `json:"email"`
|
||||
}{Verified: true, Email: email})
|
||||
}
|
||||
func challengeScope(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc {
|
||||
if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("session identifier required"))
|
||||
}
|
||||
claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes)
|
||||
claims["clientId"] = sessionID.ClientID
|
||||
claims["deviceId"] = sessionID.DeviceID
|
||||
// Independent challenges, even when password requests arrive within one second.
|
||||
claims["nonce"] = randomKey()
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
_, raw, err := ja.Encode(claims)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
||||
return pr.queueLogin(r, a, token, key, "")
|
||||
}
|
||||
func randomKey() string { return rand.Text() }
|
||||
func loginScope(raw string) factor.Scope {
|
||||
return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)}
|
||||
}
|
||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc {
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)}
|
||||
var challenge factor.Challenge
|
||||
var err error
|
||||
if id == "" {
|
||||
challenge, err = pr.factors.Begin(r.Context(), req)
|
||||
} else {
|
||||
challenge, err = pr.factors.Resend(r.Context(), req, id)
|
||||
}
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt})
|
||||
}
|
||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
||||
if len(raw) > 8192 {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
t, err := jwtauth.VerifyToken(ja, raw)
|
||||
if err != nil {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
_, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
token, err := authmodel.Claims2Token(claims)
|
||||
if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" {
|
||||
return nil, nil, merrors.Unauthorized("pending login required")
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = pr.db.Get(ctx, token.AccountRef, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return nil, nil, merrors.Unauthorized("inactive account")
|
||||
}
|
||||
return a, token, nil
|
||||
}
|
||||
func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.ChallengeID == "" {
|
||||
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID)
|
||||
}
|
||||
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID)
|
||||
if h != nil {
|
||||
return h
|
||||
}
|
||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
||||
return h
|
||||
}
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
scope := loginScope(req.PendingToken)
|
||||
var result http.HandlerFunc
|
||||
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
|
||||
var err error
|
||||
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type capturedMail struct {
|
||||
messages []notifications.Message
|
||||
fail bool
|
||||
}
|
||||
|
||||
type signupProvider struct {
|
||||
registrations, submissions int
|
||||
fail bool
|
||||
last identity.Facts
|
||||
}
|
||||
|
||||
func (*signupProvider) Namespace() string { return "test-provider" }
|
||||
func (p *signupProvider) Register(context.Context, string) (string, error) {
|
||||
p.registrations++
|
||||
return "subject", nil
|
||||
}
|
||||
func (p *signupProvider) Submit(_ context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||
p.submissions++
|
||||
p.last = facts
|
||||
if p.fail {
|
||||
return identity.Receipt{}, merrors.ServiceUnavailable("provider unavailable")
|
||||
}
|
||||
return identity.Receipt{SubjectRef: subject, SubmissionRef: "submission", AcceptedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
type activationGuard struct {
|
||||
flow.SignupFacts
|
||||
block bool
|
||||
}
|
||||
|
||||
func (g *activationGuard) Ready(ctx context.Context, id bson.ObjectID) error {
|
||||
if g.block {
|
||||
return merrors.ServiceUnavailable("intake receipt unavailable")
|
||||
}
|
||||
return g.SignupFacts.Ready(ctx, id)
|
||||
}
|
||||
|
||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
m.messages = append(m.messages, r)
|
||||
if m.fail {
|
||||
return merrors.ServiceUnavailable("test queue unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type signupWallet struct {
|
||||
customers []bson.ObjectID
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
|
||||
s.customers = append(s.customers, a.ID)
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
q.Set("directConnection", "true")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "auth_flow"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
||||
require.NoError(t, err)
|
||||
customers, err := accountservice.NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, nil)
|
||||
require.NoError(t, err)
|
||||
mail := &capturedMail{}
|
||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||
require.NoError(t, service.Validate())
|
||||
bindings, err := identity.OpenStore(factory)
|
||||
require.NoError(t, err)
|
||||
provider := &signupProvider{fail: true}
|
||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
|
||||
service.Wallet = wallet
|
||||
// Keep the real verification/customer stores and capture only session writes.
|
||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||
require.NoError(t, err)
|
||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
||||
require.NoError(t, err)
|
||||
routes := chi.NewRouter()
|
||||
Install(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
w := httptest.NewRecorder()
|
||||
routes.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||
w := post("signup", signup)
|
||||
require.Equal(t, 503, w.Code, w.Body.String())
|
||||
require.Empty(t, mail.messages)
|
||||
require.Equal(t, 1, provider.registrations)
|
||||
require.Equal(t, 503, post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "failed-resend"}).Code)
|
||||
require.Empty(t, mail.messages)
|
||||
// Only the original password can resume a pending local customer.
|
||||
signup["password"] = "different password"
|
||||
require.Equal(t, 400, post("signup", signup).Code)
|
||||
signup["password"] = " correct password "
|
||||
signup["name"] = "Changed retry name"
|
||||
provider.fail = false
|
||||
w = post("signup", signup)
|
||||
require.Equal(t, 503, w.Code, w.Body.String())
|
||||
require.Empty(t, mail.messages)
|
||||
require.Len(t, wallet.customers, 1)
|
||||
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
|
||||
require.Equal(t, 503, w.Code, w.Body.String())
|
||||
require.Empty(t, mail.messages)
|
||||
wallet.err = nil
|
||||
w = post("signup", signup)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 1, provider.registrations)
|
||||
require.Equal(t, "Customer", provider.last.FullName)
|
||||
require.Equal(t, "user@example.com", provider.last.Email)
|
||||
require.Len(t, mail.messages, 1)
|
||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||
require.Equal(t, "ru", mail.messages[0].Locale)
|
||||
activationURL, err := url.Parse(mail.messages[0].Parameters["url"])
|
||||
require.NoError(t, err)
|
||||
activation := activationURL.Query().Get("token")
|
||||
require.NotEmpty(t, activation)
|
||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
|
||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||
require.Equal(t, 403, post("login", login).Code)
|
||||
invalid := post("verification/confirm", map[string]string{"token": "invalid"})
|
||||
require.Equal(t, 400, invalid.Code)
|
||||
require.NotContains(t, invalid.Body.String(), a.Login)
|
||||
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||
service.Facts = guard
|
||||
blocked := post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 503, blocked.Code)
|
||||
require.NotContains(t, blocked.Body.String(), a.Login)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
guard.block = false
|
||||
w = post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
var confirmed struct {
|
||||
Data struct {
|
||||
Verified bool `json:"verified"`
|
||||
Email string `json:"email"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
|
||||
require.True(t, confirmed.Data.Verified)
|
||||
require.Equal(t, a.Login, confirmed.Data.Email)
|
||||
used := post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 400, used.Code)
|
||||
require.NotContains(t, used.Body.String(), a.Login)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, customer.AccountActive, a.Status)
|
||||
w = post("login", login)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 2)
|
||||
require.Empty(t, sessions.tokens)
|
||||
var pending struct {
|
||||
Data challengeResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &pending))
|
||||
require.NotEmpty(t, pending.Data.PendingToken.Token)
|
||||
require.NotContains(t, w.Body.String(), mail.messages[1].Parameters["code"])
|
||||
code := mail.messages[1].Parameters["code"]
|
||||
require.Len(t, code, 6)
|
||||
wrong := "000000"
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code)
|
||||
require.Empty(t, sessions.tokens)
|
||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code)
|
||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code})
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
// A queue outage cannot report a successful login challenge or create a session.
|
||||
mail.fail = true
|
||||
require.Equal(t, 503, post("login", login).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user