Compare commits
66
Commits
8e2ef7aa88
..
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 |
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__
|
||||||
@@ -34,3 +34,9 @@ pnpm-debug.log*
|
|||||||
/backend/notifications/interface/notifications/**/*.pb.go
|
/backend/notifications/interface/notifications/**/*.pb.go
|
||||||
/backend/notifications/config.yml
|
/backend/notifications/config.yml
|
||||||
/backend/notifications/notifications
|
/backend/notifications/notifications
|
||||||
|
|
||||||
|
# Local container pins, Vault bootstrap material and service identities
|
||||||
|
/.local/
|
||||||
|
|
||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
@@ -13,14 +13,33 @@
|
|||||||
- Never commit autogenerated code to the repository. This applies to backend,
|
- Never commit autogenerated code to the repository. This applies to backend,
|
||||||
frontend, shared packages, and every other module, including generated
|
frontend, shared packages, and every other module, including generated
|
||||||
protobuf bindings, API clients, serializers, and framework-generated code.
|
protobuf bindings, API clients, serializers, and framework-generated code.
|
||||||
- Commit the source definitions, generator configuration, and reproducible
|
- Use externally published schemas as generation inputs. Do not embed or commit
|
||||||
generation commands instead. Generate code locally or during build and CI
|
schema snapshots in the repository. Commit external schema references,
|
||||||
workflows before any dependent validation or compilation steps.
|
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
|
- Keep generated outputs ignored by Git. Do not force-add ignored generated
|
||||||
files or copy generated code into tracked source files.
|
files or copy generated code into tracked source files.
|
||||||
- Change the source definitions or generator inputs rather than editing
|
- Change schema sources in their owning project or update external schema
|
||||||
generated outputs. Document the generation workflow so a clean checkout can
|
references and generator inputs rather than editing generated outputs.
|
||||||
build without committed generated code.
|
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
|
## Data and state architecture
|
||||||
|
|
||||||
@@ -153,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
|
into task commits. Record scope, validation, limitations, and PR links in the
|
||||||
task and durable repository documentation. Merge only when authorized by 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.
|
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.
|
||||||
@@ -17,10 +17,12 @@ test-frontend:
|
|||||||
.PHONY: test-backend lint-backend run-backend
|
.PHONY: test-backend lint-backend run-backend
|
||||||
|
|
||||||
test-backend: generate-notifications
|
test-backend: generate-notifications
|
||||||
cd backend/shared && go test -race ./...
|
docker pull mongo:latest
|
||||||
cd backend/notifications/interface && go test -race ./...
|
docker pull nats:latest
|
||||||
cd backend/notifications && go test -race ./...
|
cd backend/shared && go test -race -count=1 ./...
|
||||||
cd backend/product && go test -race ./...
|
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: generate-notifications
|
lint-backend: generate-notifications
|
||||||
cd backend/product && go test ./architecture
|
cd backend/product && go test ./architecture
|
||||||
@@ -49,3 +51,51 @@ package-notifications-interface: generate-notifications
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
|
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
|
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
|
localization, email verification and recovery screens, and URL-based navigation
|
||||||
with `go_router`. Its interface uses the shared theme's colors and typography.
|
with `go_router`. Its interface uses the shared theme's colors and typography.
|
||||||
|
|
||||||
**Authentication, email verification and analytics use dummy services.** No real
|
The default preview uses dummy services. Configuring the existing backend URL
|
||||||
account or session is created, no email is delivered, and no analytics data is
|
and client ID selects authenticated BFF adapters for auth, identity, limits and
|
||||||
transmitted by those app services. The backend now contains locally owned copies
|
wallet quotations/payment execution. Optional Sendico configuration and dedicated
|
||||||
of Sendico’s BFF runtime, auth/session stack and account service. The app is not
|
customer financial resources are required for real operations; analytics still
|
||||||
connected to that backend or to Sendico yet.
|
defaults to an in-memory sink. See [payment setup and rollout](docs/payments.md).
|
||||||
|
|
||||||
## Repository structure
|
## 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
|
Flutter packages can be reused by a future mobile app; mobile runners are not
|
||||||
currently included. Local `.vscode/` configuration is ignored.
|
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
|
## Run the consumer app
|
||||||
|
|
||||||
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
|
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:
|
repository root:
|
||||||
|
|
||||||
```sh
|
```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
|
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.
|
[provenance](backend/PROVENANCE.md); the projects share no runtime modules.
|
||||||
|
|
||||||
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
|
Run `make test-backend`, `make lint-backend` or `make run-backend` from the root.
|
||||||
The copied runtime installs local authentication transport and initializes its
|
The runtime installs local authentication, verification delivery, identity and
|
||||||
account service. Signup/profile endpoints, verification delivery/completion,
|
wallet transports. Public Sendico adapters support independent customer facts,
|
||||||
Flutter HTTP adapters and live Sendico operations remain to be wired. See the
|
reviewed identity/limits and quotation/execution through the BFF. Deployment and
|
||||||
[backend run guide](backend/README.md) for local MongoDB configuration.
|
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).
|
||||||
@@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's
|
|||||||
builders, dynamic-template dependency, notification events and unused producer
|
builders, dynamic-template dependency, notification events and unused producer
|
||||||
were removed. Resend is a new driver using the same contract.
|
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
|
Shared messaging was selected from that same Sendico revision's
|
||||||
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
||||||
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
||||||
|
|||||||
@@ -12,22 +12,45 @@ pending-work state atomically in one document through the shared DB wrapper.
|
|||||||
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
||||||
transport, adapted from Sendico. A durable NATS worker claims the notification
|
transport, adapted from Sendico. A durable NATS worker claims the notification
|
||||||
with an atomic conditional patch and saves the outcome before acknowledging.
|
with an atomic conditional patch and saves the outcome before acknowledging.
|
||||||
`DriverConfig` selects the email driver: `sendgrid` or `resend`.
|
`DriverConfig` selects the email driver: `sendgrid`, `resend` or `smtp`.
|
||||||
|
|
||||||
Email adapters only map rendered email models to provider wire payloads. SendGrid
|
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
|
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
|
||||||
subject, text and HTML, bounded timeouts and safe error classification.
|
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
|
## Run
|
||||||
|
|
||||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
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.
|
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
|
||||||
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for
|
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
|
||||||
each configured caller, and the selected email provider's API key environment
|
variable. Configure a verified sender address and template
|
||||||
variable. Configure a verified sender address, TLS certificate/key and template
|
|
||||||
catalog. Paths are relative to the process working directory.
|
catalog. Paths are relative to the process working directory.
|
||||||
|
|
||||||
From the root, run `make run-notifications`. The gRPC listener always uses TLS.
|
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
|
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
|
are supported by the shared driver. Mongo and messaging both use shared
|
||||||
`DriverConfig` settings; email settings reject unknown keys.
|
`DriverConfig` settings; email settings reject unknown keys.
|
||||||
@@ -75,13 +98,41 @@ remaining status and request fingerprints expire through a seven-day TTL.
|
|||||||
- Resend receives the notification ID as its provider idempotency key. Its window
|
- Resend receives the notification ID as its provider idempotency key. Its window
|
||||||
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
||||||
delivery is not promised.
|
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
|
- Changing the configured driver fails queued jobs pinned to the old driver with
|
||||||
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
||||||
- Callers authorize recipients and preferences. No direct customer-database access,
|
- Callers authorize recipients and preferences. No direct customer-database access,
|
||||||
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
|
bulk campaigns, provider failover, SMS, push or Telegram delivery is included.
|
||||||
|
|
||||||
Structured logs report notification IDs, driver, state and attempt count; message
|
Structured logs report notification IDs, driver, state and attempt count. Provider
|
||||||
content, template values, addresses and provider response bodies are excluded.
|
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.
|
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
|
||||||
|
|
||||||
## Validate and distribute
|
## Validate and distribute
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/notifications/bus"
|
"git.sendico.io/tech/sxchnge/notifications/bus"
|
||||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||||
@@ -22,7 +21,6 @@ type Config struct {
|
|||||||
TLSKey string `yaml:"tls_key"`
|
TLSKey string `yaml:"tls_key"`
|
||||||
Database db.Config `yaml:"database"`
|
Database db.Config `yaml:"database"`
|
||||||
Templates string `yaml:"templates"`
|
Templates string `yaml:"templates"`
|
||||||
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
|
|
||||||
Email email.Config `yaml:"email"`
|
Email email.Config `yaml:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +38,14 @@ func Load(path string) (Config, error) {
|
|||||||
if err = decoder.Decode(new(any)); err != io.EOF {
|
if err = decoder.Decode(new(any)); err != io.EOF {
|
||||||
return c, merrors.InvalidArgument("unexpected configuration content")
|
return c, merrors.InvalidArgument("unexpected configuration content")
|
||||||
}
|
}
|
||||||
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 {
|
if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
|
||||||
return c, merrors.InvalidArgument("missing notification configuration")
|
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 {
|
if err = c.Queue.Validate(); err != nil {
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
|
||||||
for caller, env := range c.Callers {
|
|
||||||
token := os.Getenv(env)
|
|
||||||
if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] {
|
|
||||||
return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes")
|
|
||||||
}
|
|
||||||
seen[token] = true
|
|
||||||
}
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,10 @@ package app
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExampleConfigAndSecrets(t *testing.T) {
|
func TestExampleConfigAndSecrets(t *testing.T) {
|
||||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
|
|
||||||
c, err := Load("../config.example.yml")
|
c, err := Load("../config.example.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -16,11 +14,6 @@ func TestExampleConfigAndSecrets(t *testing.T) {
|
|||||||
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
|
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
|
||||||
t.Fatal("wrong driver configuration")
|
t.Fatal("wrong driver configuration")
|
||||||
}
|
}
|
||||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "")
|
|
||||||
if _, err = Load("../config.example.yml"); err == nil {
|
|
||||||
t.Fatal("missing credentials accepted")
|
|
||||||
}
|
|
||||||
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
|
|
||||||
data, err := os.ReadFile("../config.example.yml")
|
data, err := os.ReadFile("../config.example.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -33,3 +26,30 @@ func TestExampleConfigAndSecrets(t *testing.T) {
|
|||||||
t.Fatal("extra YAML document accepted")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,9 +32,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
|
options, err := serverOptions(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
|
return err
|
||||||
}
|
}
|
||||||
client, err := db.ConnectMongo(logger, &c.Database)
|
client, err := db.ConnectMongo(logger, &c.Database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
_ = client.Disconnect(cleanup)
|
_ = client.Disconnect(cleanup)
|
||||||
}()
|
}()
|
||||||
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
repo, err := store.New(logger, client.Database())
|
repo, err := store.New(logger, client.Database())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -58,16 +55,12 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
}
|
}
|
||||||
defer rt.Close()
|
defer rt.Close()
|
||||||
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
|
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
|
||||||
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer broker.Close()
|
defer broker.Close()
|
||||||
tokens := map[string]string{}
|
server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
|
||||||
for caller, env := range c.Callers {
|
|
||||||
tokens[caller] = os.Getenv(env)
|
|
||||||
}
|
|
||||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
|
|
||||||
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||||
listener, err := net.Listen("tcp", c.Listen)
|
listener, err := net.Listen("tcp", c.Listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,3 +96,15 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
|||||||
logger.Info("Notifications service stopped")
|
logger.Info("Notifications service stopped")
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
@@ -35,9 +35,13 @@ func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin
|
|||||||
}
|
}
|
||||||
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
|
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}
|
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
|
||||||
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil {
|
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")
|
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)
|
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
|
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type outbox struct{ jobs []model.Notification }
|
type outbox struct{ jobs []model.Notification }
|
||||||
@@ -23,6 +24,50 @@ type publisher struct {
|
|||||||
records []reliable.Record
|
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 {
|
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
|
||||||
if p.fail {
|
if p.fail {
|
||||||
return merrors.ServiceUnavailable("offline")
|
return merrors.ServiceUnavailable("offline")
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
listen: "127.0.0.1:8083"
|
listen: "127.0.0.1:8083"
|
||||||
tls_cert: "server.crt"
|
|
||||||
tls_key: "server.key"
|
|
||||||
database:
|
database:
|
||||||
driver: mongodb
|
driver: mongodb
|
||||||
settings:
|
settings:
|
||||||
@@ -14,10 +12,8 @@ messaging:
|
|||||||
max_reconnects: -1
|
max_reconnects: -1
|
||||||
reconnect_wait: 2
|
reconnect_wait: 2
|
||||||
templates: "templates.example.json"
|
templates: "templates.example.json"
|
||||||
callers:
|
|
||||||
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
|
|
||||||
email:
|
email:
|
||||||
driver: resend # sendgrid or resend
|
driver: resend # sendgrid or resend; see README for authenticated SMTP settings
|
||||||
settings:
|
settings:
|
||||||
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
||||||
sender_address: "notifications@example.com"
|
sender_address: "notifications@example.com"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Driver string
|
|||||||
const (
|
const (
|
||||||
SendGrid Driver = "sendgrid"
|
SendGrid Driver = "sendgrid"
|
||||||
Resend Driver = "resend"
|
Resend Driver = "resend"
|
||||||
|
SMTP Driver = "smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config = sharedmodel.DriverConfig[Driver]
|
type Config = sharedmodel.DriverConfig[Driver]
|
||||||
@@ -33,6 +34,11 @@ type Outcome struct {
|
|||||||
State model.State
|
State model.State
|
||||||
Code string
|
Code string
|
||||||
RetryAfter time.Duration
|
RetryAfter time.Duration
|
||||||
|
// Provider diagnostics are sanitized operational data, not public failures.
|
||||||
|
HTTPStatus int
|
||||||
|
SMTPStatus int
|
||||||
|
ProviderError string
|
||||||
|
ProviderMessage string
|
||||||
}
|
}
|
||||||
type Sender interface {
|
type Sender interface {
|
||||||
Send(context.Context, string, model.Email) Outcome
|
Send(context.Context, string, model.Email) Outcome
|
||||||
@@ -46,6 +52,9 @@ type client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) (Sender, error) {
|
func New(config Config) (Sender, error) {
|
||||||
|
if config.Driver == SMTP {
|
||||||
|
return newSMTP(config)
|
||||||
|
}
|
||||||
var s Settings
|
var s Settings
|
||||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,14 +28,15 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
|||||||
}
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
// Discard a bounded response without logging provider-controlled content.
|
// Never pass raw provider bodies through the outcome or log them directly.
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
|
||||||
|
outcome := Outcome{HTTPStatus: resp.StatusCode}
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||||
return Outcome{State: model.Accepted}
|
outcome.State = model.Accepted
|
||||||
case resp.StatusCode == 429:
|
case resp.StatusCode == 429:
|
||||||
delay := time.Minute
|
delay := time.Minute
|
||||||
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
||||||
@@ -43,12 +44,20 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
|||||||
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
||||||
delay = time.Until(date)
|
delay = time.Until(date)
|
||||||
}
|
}
|
||||||
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay}
|
outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
|
||||||
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
||||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
|
||||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||||
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
|
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
|
||||||
default:
|
default:
|
||||||
return Outcome{State: model.Failed, Code: "provider_rejected"}
|
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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,14 @@ Internal Go consumers use the module
|
|||||||
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
|
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
|
||||||
checkout, use a local replace to this directory and generate before building.
|
checkout, use a local replace to this directory and generate before building.
|
||||||
|
|
||||||
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service
|
Calls use the trusted private network without caller authentication. The service
|
||||||
maps tokens to configured caller identities. Callers authorize user/destination
|
uses the existing product storage namespace. Callers authorize user/destination
|
||||||
ownership and preferences before sending; this contract is not a public user API.
|
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
|
Parameters are string values keyed by template parameter name. The entire request
|
||||||
is validated before persistence. An identical idempotency key/request returns the
|
is validated before persistence. An identical idempotency key/request returns the
|
||||||
original resource, even after completion or expiry. A changed request conflicts.
|
original resource, even after completion or expiry. A changed request conflicts.
|
||||||
Keys are scoped to the authenticated caller and retained at least seven days;
|
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
|
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.
|
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package sxchnge.notifications.v1;
|
|||||||
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
|
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
|
||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
// Internal service: authenticate callers over TLS. Send durably queues work.
|
// Internal service on the trusted private network; no caller authentication. Send durably queues work.
|
||||||
service NotificationsService {
|
service NotificationsService {
|
||||||
rpc Send(SendRequest) returns (SendResponse);
|
rpc Send(SendRequest) returns (SendResponse);
|
||||||
rpc Get(GetRequest) returns (GetResponse);
|
rpc Get(GetRequest) returns (GetResponse);
|
||||||
}
|
}
|
||||||
message SendRequest {
|
message SendRequest {
|
||||||
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days.
|
string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
|
||||||
string user_id = 2; // Caller resolves and authorizes recipient ownership.
|
string user_id = 2; // Caller resolves and authorizes recipient ownership.
|
||||||
string template_id = 3;
|
string template_id = 3;
|
||||||
string template_version = 4;
|
string template_version = 4;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package service_test
|
package service_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -17,10 +16,9 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/notifications/transport"
|
"git.sendico.io/tech/sxchnge/notifications/transport"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/metadata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exercise authenticated TLS transport against the real service and storage.
|
// Exercise optional TLS transport against the real service and storage.
|
||||||
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
|
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
@@ -42,7 +40,7 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})), grpc.UnaryInterceptor(transport.Authenticate(map[string]string{"product": "product-test-token", "other": "other-test-token"})))
|
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))
|
pb.RegisterNotificationsServiceServer(server, transport.New(svc))
|
||||||
go func() { _ = server.Serve(listener) }()
|
go func() { _ = server.Serve(listener) }()
|
||||||
t.Cleanup(server.Stop)
|
t.Cleanup(server.Stop)
|
||||||
@@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
|
|||||||
t.Cleanup(func() { _ = conn.Close() })
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
return pb.NewNotificationsServiceClient(conn)
|
return pb.NewNotificationsServiceClient(conn)
|
||||||
}
|
}
|
||||||
func callerContext(ctx context.Context, caller string) context.Context {
|
|
||||||
return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token"))
|
|
||||||
}
|
|
||||||
@@ -26,8 +26,6 @@ import (
|
|||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type sender struct {
|
type sender struct {
|
||||||
@@ -50,7 +48,7 @@ func (s *sender) Send(_ context.Context, id string, message model.Email) email.O
|
|||||||
func TestDurableDelivery(t *testing.T) {
|
func TestDurableDelivery(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
container, err := mongodb.Run(ctx, "mongo:8.0")
|
container, err := mongodb.Run(ctx, "mongo:latest")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -192,7 +190,7 @@ func TestDurableDelivery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Verify the real JetStream publisher/consumer path with an isolated broker.
|
// Verify the real JetStream publisher/consumer path with an isolated broker.
|
||||||
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := grpcClient(t, svc)
|
client := grpcClient(t, svc)
|
||||||
rpcCtx := callerContext(ctx, "product")
|
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"}}}}}
|
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"}}}}}
|
||||||
if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated {
|
|
||||||
t.Fatal("unauthenticated request accepted", e)
|
|
||||||
}
|
|
||||||
sent, err := client.Send(rpcCtx, input)
|
sent, err := client.Send(rpcCtx, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) {
|
|||||||
if err != nil || replay.Notification.Id != sent.Notification.Id {
|
if err != nil || replay.Notification.Id != sent.Notification.Id {
|
||||||
t.Fatal("RPC idempotency failed", err)
|
t.Fatal("RPC idempotency failed", err)
|
||||||
}
|
}
|
||||||
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound {
|
if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
|
||||||
t.Fatal("RPC caller isolation failed", e)
|
t.Fatal("RPC retrieval without credentials failed", e)
|
||||||
}
|
}
|
||||||
if err = broker.PublishDue(ctx); err != nil {
|
if err = broker.PublishDue(ctx); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -23,6 +24,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
var outcome email.Outcome
|
||||||
switch {
|
switch {
|
||||||
case !n.ExpiresAt.After(now):
|
case !n.ExpiresAt.After(now):
|
||||||
n.State = model.Expired
|
n.State = model.Expired
|
||||||
@@ -36,7 +38,7 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
deadline = n.ExpiresAt
|
deadline = n.ExpiresAt
|
||||||
}
|
}
|
||||||
sendCtx, cancel := context.WithDeadline(ctx, deadline)
|
sendCtx, cancel := context.WithDeadline(ctx, deadline)
|
||||||
outcome := s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
|
outcome = s.sender.Send(sendCtx, n.ID.Hex(), n.Email)
|
||||||
cancel()
|
cancel()
|
||||||
n.State = outcome.State
|
n.State = outcome.State
|
||||||
n.FailureCode = outcome.Code
|
n.FailureCode = outcome.Code
|
||||||
@@ -62,14 +64,27 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
// Persist the result even when shutdown canceled the provider request.
|
// Persist the result even when shutdown canceled the provider request.
|
||||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err = s.repo.Finish(finishCtx, n); err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
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)}
|
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 {
|
if n.State == model.Accepted {
|
||||||
s.logger.Info("Email accepted by provider", fields...)
|
s.logger.Info("Email accepted by provider", fields...)
|
||||||
} else {
|
} else {
|
||||||
s.logger.Warn("Email delivery attempt finished", fields...)
|
s.logger.Warn("Email delivery attempt finished", fields...)
|
||||||
}
|
}
|
||||||
|
if err = s.repo.Finish(finishCtx, n); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,23 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/notifications/email"
|
"git.sendico.io/tech/sxchnge/notifications/email"
|
||||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
)
|
)
|
||||||
|
|
||||||
type workerRepo struct {
|
type workerRepo struct {
|
||||||
Repository
|
Repository
|
||||||
job model.Notification
|
job model.Notification
|
||||||
saved *model.Notification
|
saved *model.Notification
|
||||||
|
finishErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
|
func (r *workerRepo) Recover(context.Context, time.Time) error { return nil }
|
||||||
@@ -21,7 +26,10 @@ func (r *workerRepo) Claim(context.Context, string, time.Time) (*model.Notificat
|
|||||||
n := r.job
|
n := r.job
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error { r.saved = n; return nil }
|
func (r *workerRepo) Finish(_ context.Context, n *model.Notification) error {
|
||||||
|
r.saved = n
|
||||||
|
return r.finishErr
|
||||||
|
}
|
||||||
|
|
||||||
type workerSender struct {
|
type workerSender struct {
|
||||||
calls int
|
calls int
|
||||||
@@ -62,3 +70,36 @@ func TestWorkerTerminalAndRetryOutcomes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
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"
|
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ type Store struct {
|
|||||||
template.DBImp[*model.Notification]
|
template.DBImp[*model.Notification]
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) {
|
func New(logger mlogger.Logger, db source.Source) (*Store, error) {
|
||||||
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
|
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
|
||||||
ttl := int32(0)
|
ttl := int32(0)
|
||||||
for _, index := range []*ri.Definition{
|
for _, index := range []*ri.Definition{
|
||||||
|
|||||||
@@ -2,21 +2,15 @@ package transport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
|
|
||||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type callerKey struct{}
|
|
||||||
type API interface {
|
type API interface {
|
||||||
Send(context.Context, string, model.Request) (*model.Notification, error)
|
Send(context.Context, string, model.Request) (*model.Notification, error)
|
||||||
Get(context.Context, string, string) (*model.Notification, error)
|
Get(context.Context, string, string) (*model.Notification, error)
|
||||||
@@ -26,14 +20,14 @@ type Server struct {
|
|||||||
service API
|
service API
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The private endpoint uses the existing product storage namespace without caller authentication.
|
||||||
func New(service API) *Server { return &Server{service: service} }
|
func New(service API) *Server { return &Server{service: service} }
|
||||||
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
|
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
|
||||||
input, err := request(r)
|
input, err := request(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, rpcError(err)
|
return nil, rpcError(err)
|
||||||
}
|
}
|
||||||
caller, _ := ctx.Value(callerKey{}).(string)
|
n, err := s.service.Send(ctx, "product", input)
|
||||||
n, err := s.service.Send(ctx, caller, input)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, rpcError(err)
|
return nil, rpcError(err)
|
||||||
}
|
}
|
||||||
@@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
|
|||||||
if r == nil {
|
if r == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing request")
|
return nil, status.Error(codes.InvalidArgument, "missing request")
|
||||||
}
|
}
|
||||||
caller, _ := ctx.Value(callerKey{}).(string)
|
n, err := s.service.Get(ctx, "product", r.Id)
|
||||||
n, err := s.service.Get(ctx, caller, r.Id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, rpcError(err)
|
return nil, rpcError(err)
|
||||||
}
|
}
|
||||||
return &pb.GetResponse{Notification: response(n)}, nil
|
return &pb.GetResponse{Notification: response(n)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tokens are mapped to stable server-configured caller identities, never supplied IDs.
|
|
||||||
func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor {
|
|
||||||
hashes := map[string][32]byte{}
|
|
||||||
for caller, token := range tokens {
|
|
||||||
hashes[caller] = sha256.Sum256([]byte(token))
|
|
||||||
}
|
|
||||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
|
||||||
md, _ := metadata.FromIncomingContext(ctx)
|
|
||||||
values := md.Get("authorization")
|
|
||||||
if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
|
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
|
|
||||||
}
|
|
||||||
hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer ")))
|
|
||||||
identity := ""
|
|
||||||
for caller, expected := range hashes {
|
|
||||||
if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 {
|
|
||||||
identity = caller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if identity == "" {
|
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
|
|
||||||
}
|
|
||||||
return handler(context.WithValue(ctx, callerKey{}, identity), req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func rpcError(err error) error {
|
func rpcError(err error) error {
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return status.Error(codes.Canceled, "notification operation canceled")
|
return status.Error(codes.Canceled, "notification operation canceled")
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/grpc/metadata"
|
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
)
|
)
|
||||||
@@ -31,10 +30,9 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N
|
|||||||
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
|
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
|
||||||
return nil, merrors.NoData("private details")
|
return nil, merrors.NoData("private details")
|
||||||
}
|
}
|
||||||
func TestGRPCAuthenticationAndMapping(t *testing.T) {
|
func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
|
||||||
listener := bufconn.Listen(1 << 20)
|
listener := bufconn.Listen(1 << 20)
|
||||||
token := strings.Repeat("a", 32)
|
server := grpc.NewServer()
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
|
|
||||||
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
|
||||||
go func() { _ = server.Serve(listener) }()
|
go func() { _ = server.Serve(listener) }()
|
||||||
defer server.Stop()
|
defer server.Stop()
|
||||||
@@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) {
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
client := pb.NewNotificationsServiceClient(conn)
|
client := pb.NewNotificationsServiceClient(conn)
|
||||||
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
|
||||||
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated {
|
ctx := context.Background()
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
|
|
||||||
result, err := client.Send(ctx, req)
|
result, err := client.Send(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -28,14 +28,25 @@ Signup and email verification delivery/completion are available when
|
|||||||
`api.notifications` is configured. Flutter selects the backend using
|
`api.notifications` is configured. Flutter selects the backend using
|
||||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||||
Profile HTTP wiring remains follow-up work.
|
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
|
Optional `api.sendico` connects sign-up to the public individual registry and
|
||||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
immutable Facts intake. The adapter owns organization context and consumed wire
|
||||||
copy only the wire definitions consumed by an implemented adapter.
|
contracts; local customers retain no platform tenancy. See
|
||||||
|
[configuration, recovery and measurement](../../docs/signup-facts.md).
|
||||||
|
|
||||||
## Validate
|
## Validate
|
||||||
|
|
||||||
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
|
`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
|
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
|
||||||
validate `shared`. See [source provenance](../PROVENANCE.md).
|
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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
"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/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type API interface {
|
// Dependencies are initialized once and shared by installed transports.
|
||||||
LifecycleContext() context.Context
|
type Dependencies struct {
|
||||||
Logger() mlogger.Logger
|
Stores *storage.Stores
|
||||||
DomainProvider() domainprovider.DomainProvider
|
Customers accountservice.AccountService
|
||||||
Config() *Config
|
Auth *flow.Service
|
||||||
ProfileConfig() ConfigProvider
|
Factors *twofactor.Service
|
||||||
DBFactory() db.Factory
|
Identity *identity.Service
|
||||||
RuntimeComponent(name string) any
|
Payments *payments.Service
|
||||||
Register() Register
|
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"`
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@ package api
|
|||||||
import (
|
import (
|
||||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
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"`
|
Notifications *notifications.Config `yaml:"notifications"`
|
||||||
Mw *mwa.Config `yaml:"middleware"`
|
Mw *mwa.Config `yaml:"middleware"`
|
||||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||||
|
|||||||
@@ -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
|
package api
|
||||||
|
|
||||||
import "git.sendico.io/tech/sxchnge/shared/service"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
type ConfigProvider interface {
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
SharedConfig() *Config
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
}
|
)
|
||||||
|
|
||||||
|
type ConfigProvider interface{ SharedConfig() *Config }
|
||||||
type ConfigFactory func() ConfigProvider
|
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 {
|
type RuntimeConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Services []ServiceFactory
|
|
||||||
ConfigFactory ConfigFactory
|
ConfigFactory ConfigFactory
|
||||||
Bootstrap RuntimeBootstrapFunc
|
Bootstrap Bootstrap
|
||||||
Finish RuntimeFinishFunc
|
Versions map[string]Installer
|
||||||
}
|
|
||||||
|
|
||||||
type ServiceFactory struct {
|
|
||||||
Name mservice.Type
|
|
||||||
Factory MicroServiceFactoryT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg RuntimeConfig) RuntimeName() string {
|
func (cfg RuntimeConfig) RuntimeName() string {
|
||||||
if cfg.Name == "" {
|
if cfg.Name == "" {
|
||||||
return "edge"
|
return "product"
|
||||||
}
|
}
|
||||||
return cfg.Name
|
return cfg.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
||||||
if cfg.ConfigFactory != nil {
|
if cfg.ConfigFactory != nil {
|
||||||
return cfg.ConfigFactory()
|
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,26 +2,12 @@ package httptransport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
|
||||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
|
||||||
public "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
|
||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
|
||||||
delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
|
||||||
factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http"
|
|
||||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"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/http/response"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
@@ -34,33 +20,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
notifications *notifications.Client
|
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
apiEndpoint string
|
apiEndpoint string
|
||||||
metrics mr.Metrics
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mw *Middleware) Finish() {
|
func (mw *Middleware) Finish() {
|
||||||
|
if mw.metrics != nil {
|
||||||
mw.metrics.Finish()
|
mw.metrics.Finish()
|
||||||
if mw.notifications != nil {
|
|
||||||
_ = mw.notifications.Close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||||
mw.logger.Debug("Installing middleware stack...")
|
mw.logger.Debug("Installing middleware stack...")
|
||||||
// Collect metrics for application traffic only. Scrapes and health probes are
|
// Collect metrics for application traffic only. Scrapes and health probes are
|
||||||
@@ -112,15 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
|||||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateMiddleware(
|
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
|
||||||
logger mlogger.Logger,
|
|
||||||
db db.Factory,
|
|
||||||
apiRouter *chi.Mux,
|
|
||||||
config *middleware.Config,
|
|
||||||
authStorage *middleware.AuthStorageConfig,
|
|
||||||
debug bool,
|
|
||||||
notificationConfig ...*notifications.Config,
|
|
||||||
) (*Middleware, error) {
|
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||||
}
|
}
|
||||||
@@ -130,72 +92,12 @@ func CreateMiddleware(
|
|||||||
if err := config.CORS.Validate(); err != nil {
|
if err := config.CORS.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
loginRateLimits, err := config.ResolveLoginRateLimits()
|
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
|
||||||
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.installMiddleware(config, debug)
|
p.installMiddleware(config, debug)
|
||||||
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
var err error
|
||||||
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var authFlow *flow.Service
|
|
||||||
var factors *twofactor.Service
|
|
||||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
|
||||||
c := notificationConfig[0]
|
|
||||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
|
|
||||||
if err = authFlow.Validate(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.notifications, err = notifications.Open(*c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
authFlow.Sender = p.notifications
|
|
||||||
records, err := factorstore.Open(p.logger, db)
|
|
||||||
if err != nil {
|
|
||||||
_ = p.notifications.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications})
|
|
||||||
if err != nil {
|
|
||||||
_ = p.notifications.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flows := public.Flows{Onboarding: authFlow}
|
|
||||||
if factors != nil {
|
|
||||||
flows.Factors = factors
|
|
||||||
}
|
|
||||||
p.epdispatcher = httprouter.NewDispatcher(
|
|
||||||
p.logger,
|
|
||||||
p.router,
|
|
||||||
stores.Accounts,
|
|
||||||
stores.RefreshTokens,
|
|
||||||
stores.LoginAttempts,
|
|
||||||
config,
|
|
||||||
loginRateLimits,
|
|
||||||
flows,
|
|
||||||
)
|
|
||||||
if factors != nil {
|
|
||||||
factorhttp.Install(p.epdispatcher, factors, p.logger)
|
|
||||||
}
|
|
||||||
return p, nil
|
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
|
package profile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"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 {
|
func Runtime() api.RuntimeConfig {
|
||||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
|
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
@@ -2,68 +2,20 @@ package runtime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
|
||||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
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/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Microservices = []mservice.MicroService
|
type instance struct {
|
||||||
|
|
||||||
// APIImp represents the structure of the APIImp
|
|
||||||
type APIImp struct {
|
|
||||||
lifecycleCtx context.Context
|
|
||||||
logger mlogger.Logger
|
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
|
health amr.Health
|
||||||
mw *httptransport.Middleware
|
mw *httptransport.Middleware
|
||||||
|
cleanup func(context.Context) error
|
||||||
|
once sync.Once
|
||||||
|
finishErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *APIImp) LifecycleContext() context.Context {
|
func (a *instance) Name() string { return "api" }
|
||||||
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"
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,12 @@ import (
|
|||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
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"
|
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
"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/database"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -16,93 +19,67 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CreateAPI(
|
func CreateAPI(
|
||||||
lifecycleCtx context.Context,
|
ctx context.Context,
|
||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
config *api.Config,
|
config *api.Config,
|
||||||
profileConfig api.ConfigProvider,
|
|
||||||
db db.Factory,
|
db db.Factory,
|
||||||
rootRouter *chi.Mux,
|
rootRouter, apiRouter *chi.Mux,
|
||||||
apiRouter *chi.Mux,
|
|
||||||
debug bool,
|
debug bool,
|
||||||
runtime api.RuntimeConfig,
|
runtime api.RuntimeConfig,
|
||||||
) (mservice.MicroService, error) {
|
) (_ mservice.MicroService, result error) {
|
||||||
p := &APIImp{
|
if config == nil || config.Mw == nil {
|
||||||
lifecycleCtx: lifecycleCtx,
|
return nil, merrors.InvalidArgument("API middleware configuration is required")
|
||||||
logger: logger.Named("api"),
|
|
||||||
config: config,
|
|
||||||
profile: profileConfig,
|
|
||||||
db: db,
|
|
||||||
runtime: runtime,
|
|
||||||
}
|
}
|
||||||
|
endpoint := os.Getenv(config.Mw.EndPointEnv)
|
||||||
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
a.config.Notifications,
|
|
||||||
); 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
return nil, err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
a.components = components
|
if runtime.Bootstrap == nil {
|
||||||
a.logger.Info("Runtime profile bootstrap complete",
|
return nil, merrors.InvalidArgument("runtime bootstrap is required")
|
||||||
zap.String("runtime", a.runtime.RuntimeName()),
|
|
||||||
zap.Int("components", len(components)))
|
|
||||||
}
|
}
|
||||||
return nil
|
// 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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
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,16 +2,20 @@ package runtime_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/providers/sendico"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -24,7 +28,7 @@ import (
|
|||||||
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
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)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||||
uri, err := container.ConnectionString(ctx)
|
uri, err := container.ConnectionString(ctx)
|
||||||
@@ -48,10 +52,9 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
|||||||
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
||||||
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
||||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||||
t.Setenv("SXCHNGE_NOTIFICATIONS_TOKEN", "test-notification-token-at-least-32-bytes")
|
|
||||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
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)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
||||||
root.Mount("/", routes)
|
root.Mount("/", routes)
|
||||||
@@ -62,4 +65,22 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
|||||||
result = httptest.NewRecorder()
|
result = httptest.NewRecorder()
|
||||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
|
||||||
require.Equal(t, http.StatusBadRequest, result.Code)
|
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"
|
"context"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *APIImp) Finish(ctx context.Context) error {
|
func (a *instance) Finish(ctx context.Context) error {
|
||||||
|
a.once.Do(func() {
|
||||||
|
if a.health != nil {
|
||||||
a.health.SetStatus(health.SSTerminating)
|
a.health.SetStatus(health.SSTerminating)
|
||||||
a.health.Finish()
|
a.health.Finish()
|
||||||
|
}
|
||||||
|
if a.cleanup != nil {
|
||||||
|
a.finishErr = a.cleanup(ctx)
|
||||||
|
}
|
||||||
|
if a.mw != nil {
|
||||||
a.mw.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()))
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
if a.runtime.Finish != nil {
|
return a.finishErr
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastError
|
|
||||||
}
|
}
|
||||||
@@ -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
|
package runtime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"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"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
|
||||||
a.services = append(a.services, srv)
|
limits, err := config.Mw.ResolveLoginRateLimits()
|
||||||
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
|
||||||
srv, err := srvf(a)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
return nil, nil, err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
|
||||||
a.installMicroservice(srv)
|
if err != nil {
|
||||||
return nil
|
return nil, nil, err
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) installServices() error {
|
|
||||||
for _, service := range a.runtime.Services {
|
|
||||||
if service.Factory == nil {
|
|
||||||
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
|
||||||
}
|
}
|
||||||
if err := a.addMicroservice(service.Factory); err != nil {
|
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
|
||||||
return err
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
a.health.SetStatus(health.SSRunning)
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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/product/app/api"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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()
|
rootRouter := chi.NewRouter()
|
||||||
apiRouter := 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()
|
i.db.CloseConnection()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type DomainProvider interface {
|
|||||||
GetAPILink(linkElem ...string) (string, error)
|
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) {
|
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
|
||||||
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
|
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@ package domainproviderimp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
|
|||||||
return url.JoinPath(dp.apiEndpoint, linkElem...)
|
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 := new(DomainProviderImp)
|
||||||
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
|
p.domain = protocol + "://" + domain
|
||||||
var err error
|
|
||||||
apiPath := os.Getenv(apiPointEnv)
|
|
||||||
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
|
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))
|
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
|
||||||
return nil, err
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,18 @@ import (
|
|||||||
|
|
||||||
const Cooldown = time.Minute
|
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 {
|
type Service struct {
|
||||||
|
Facts SignupFacts
|
||||||
|
Wallet CurrencyAccounts
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -85,20 +96,30 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
if err = s.Limit(ctx, ip, email); err != nil {
|
if err = s.Limit(ctx, ip, email); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
raw, err := s.Customers.CreateAccountWithActivationKey(ctx, a, s.activationKey(a.Login, key))
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||||
if lookup != nil {
|
if lookup != nil {
|
||||||
return nil, lookup
|
return nil, lookup
|
||||||
}
|
}
|
||||||
// A retry after a queue failure may resume only with the original password.
|
// A retry after a queue failure may resume only with the original password.
|
||||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
if existing.Status != customer.AccountPendingVerification || existing.IsArchived() || !existing.MatchPassword(a.Password) {
|
||||||
return nil, merrors.DataConflict("email already registered")
|
return nil, merrors.DataConflict("email already registered")
|
||||||
}
|
}
|
||||||
a = existing
|
a = existing
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
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 != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -132,14 +153,27 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil
|
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)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
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) {
|
func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key string) (time.Time, error) {
|
||||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||||
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
return time.Time{}, merrors.InvalidArgument("idempotency key required")
|
||||||
}
|
}
|
||||||
secretKey := s.key(a.ID.Hex(), string(verificationmodel.PurposeAccountActivation), "", key)
|
secretKey := s.activationKey(a.Login, key)
|
||||||
req := verification.NewLinkRequest(a.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24 * time.Hour).WithCooldown(Cooldown).WithIdempotencyKey(secretKey)
|
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()))
|
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, time.Now().UTC()))
|
||||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||||
@@ -160,10 +194,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s
|
|||||||
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()}})
|
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
|
return result.ExpiresAt, err
|
||||||
}
|
}
|
||||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
|
||||||
if len(raw) != 43 {
|
if len(raw) != 43 {
|
||||||
return verification.ErorrTokenNotFound()
|
return "", verification.ErorrTokenNotFound()
|
||||||
}
|
}
|
||||||
|
var email string
|
||||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,7 +211,19 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
|||||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||||
return nil, merrors.Unauthorized("account cannot be activated")
|
return nil, merrors.Unauthorized("account cannot be activated")
|
||||||
}
|
}
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
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
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
|||||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||||
service.Secret = []byte(strings.Repeat("b", 32))
|
service.Secret = []byte(strings.Repeat("b", 32))
|
||||||
require.NotEqual(t, key, service.key(a.ID.Hex(), "account_activation", "client-key"))
|
require.NotEqual(t, key, service.activationKey(a.Login, "client-key"))
|
||||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||||
_, err = service.sendActivation(context.Background(), a, "expired")
|
_, err = service.sendActivation(context.Background(), a, "expired")
|
||||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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"
|
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/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
|||||||
accounts.customer.Status = status
|
accounts.customer.Status = status
|
||||||
routes := chi.NewRouter()
|
routes := chi.NewRouter()
|
||||||
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
|
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
|
called := false
|
||||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
called = true
|
called = true
|
||||||
+1
-1
@@ -6,9 +6,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"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/ipguard"
|
||||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
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/product/sessions/metrics"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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"
|
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||||
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
|
|||||||
service mservice.Type
|
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)
|
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||||
router.Use(jwtauth.Verifier(ja))
|
router.Use(jwtauth.Verifier(ja))
|
||||||
router.Use(jwtauth.Authenticator(ja))
|
router.Use(jwtauth.Authenticator(ja))
|
||||||
File renamed without changes.
+9
-11
@@ -1,12 +1,10 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
|
||||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
@@ -34,13 +32,15 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
|
|||||||
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDispatcher(
|
func Install(
|
||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
|
endpoint string,
|
||||||
db account.DB,
|
db account.DB,
|
||||||
rtdb refreshtokens.DB,
|
rtdb refreshtokens.DB,
|
||||||
attempts ratelimits.Store,
|
attempts ratelimits.Store,
|
||||||
config *middleware.Config,
|
token *middleware.TokenConfig,
|
||||||
|
signature middleware.Signature,
|
||||||
loginRateLimits middleware.LoginRateLimits,
|
loginRateLimits middleware.LoginRateLimits,
|
||||||
flows ...rpublic.Flows,
|
flows ...rpublic.Flows,
|
||||||
) *Dispatcher {
|
) *Dispatcher {
|
||||||
@@ -49,13 +49,11 @@ func NewDispatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
d.logger.Debug("Installing endpoints middleware...")
|
d.logger.Debug("Installing endpoints middleware...")
|
||||||
endpoint := os.Getenv(config.EndPointEnv)
|
|
||||||
signature := middleware.SignatureConf(config)
|
|
||||||
router.Group(func(r chi.Router) {
|
router.Group(func(r chi.Router) {
|
||||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
||||||
})
|
})
|
||||||
router.Group(func(r chi.Router) {
|
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
|
return d
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"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"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"github.com/go-chi/chi/v5"
|
"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 {
|
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) {
|
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"
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"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")
|
||||||
|
}
|
||||||
+2
-2
@@ -9,9 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
clientauth "git.sendico.io/tech/sxchnge/product/auth/client"
|
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/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"
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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/ipguard"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
+2
-2
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"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/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -183,7 +183,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
|||||||
PrivateKey: []byte("test-secret"),
|
PrivateKey: []byte("test-secret"),
|
||||||
}
|
}
|
||||||
|
|
||||||
router := NewRouter(
|
router := Install(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
"/",
|
"/",
|
||||||
&testAccountDB{result: account},
|
&testAccountDB{result: account},
|
||||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"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/browser"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
+1
-1
@@ -47,7 +47,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
|||||||
Algorithm: "HS256",
|
Algorithm: "HS256",
|
||||||
PrivateKey: []byte("browser-refresh-test-secret"),
|
PrivateKey: []byte("browser-refresh-test-secret"),
|
||||||
}
|
}
|
||||||
return NewRouter(
|
return Install(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
"/api/v1",
|
"/api/v1",
|
||||||
&testAccountDB{result: account},
|
&testAccountDB{result: account},
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"net/http"
|
"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"
|
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
|
||||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"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/http/response"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
+3
-3
@@ -5,8 +5,8 @@ import (
|
|||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
@@ -41,7 +41,7 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
|
|||||||
pr.imp.InstallHandler(service, endpoint, method, handler)
|
pr.imp.InstallHandler(service, endpoint, method, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(
|
func Install(
|
||||||
logger mlogger.Logger,
|
logger mlogger.Logger,
|
||||||
apiEndpoint string,
|
apiEndpoint string,
|
||||||
db account.DB,
|
db account.DB,
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||||
+9
-3
@@ -11,9 +11,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sresponse "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/ipguard"
|
||||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||||
@@ -72,6 +72,8 @@ func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
|||||||
return pr.loginRateLimited(details.RetryAfter)
|
return pr.loginRateLimited(details.RetryAfter)
|
||||||
}
|
}
|
||||||
switch {
|
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):
|
case errors.Is(err, verification.ErrTokenExpired):
|
||||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||||
@@ -137,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
|||||||
if err := decodeFlow(r, &req); err != nil {
|
if err := decodeFlow(r, &req); err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
email, err := pr.flow.Activate(r.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Account activation completed")
|
pr.logger.Info("Account activation completed")
|
||||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
return response.Accepted(pr.logger, struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}{Verified: true, Email: email})
|
||||||
}
|
}
|
||||||
func challengeScope(raw string) string {
|
func challengeScope(raw string) string {
|
||||||
h := sha256.Sum256([]byte(raw))
|
h := sha256.Sum256([]byte(raw))
|
||||||
+104
-4
@@ -15,6 +15,7 @@ import (
|
|||||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +36,38 @@ type capturedMail struct {
|
|||||||
fail bool
|
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 {
|
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||||
m.messages = append(m.messages, r)
|
m.messages = append(m.messages, r)
|
||||||
if m.fail {
|
if m.fail {
|
||||||
@@ -41,10 +75,21 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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) {
|
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
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)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||||
uri, err := container.ConnectionString(ctx)
|
uri, err := container.ConnectionString(ctx)
|
||||||
@@ -64,6 +109,12 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
mail := &capturedMail{}
|
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"}
|
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())
|
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.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -71,7 +122,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
routes := chi.NewRouter()
|
routes := chi.NewRouter()
|
||||||
NewRouter(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})
|
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 {
|
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||||
raw, err := json.Marshal(body)
|
raw, err := json.Marshal(body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -83,7 +134,30 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||||
w := post("signup", signup)
|
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, 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.Len(t, mail.messages, 1)
|
||||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||||
@@ -95,12 +169,38 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
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"}
|
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)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).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})
|
w = post("verification/confirm", map[string]string{"token": activation})
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
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)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, customer.AccountActive, a.Status)
|
require.Equal(t, customer.AccountActive, a.Status)
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"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"
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
)
|
)
|
||||||
@@ -9,6 +9,17 @@ database:
|
|||||||
uri_env: SXCHNGE_MONGO_URI
|
uri_env: SXCHNGE_MONGO_URI
|
||||||
database: sxchnge
|
database: sxchnge
|
||||||
api:
|
api:
|
||||||
|
# Optional public Sendico facts integration. Enable with provisioned values;
|
||||||
|
# omit this block to retain local-only sign-up. See docs/signup-facts.md.
|
||||||
|
# sendico:
|
||||||
|
# api_url: https://sendico.example/api/v1
|
||||||
|
# organization_ref: <sxchnge-organization-object-id>
|
||||||
|
# source_account_ref: <integration-account-object-id>
|
||||||
|
# token_file: /run/sxchnge/sendico-token
|
||||||
|
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
||||||
|
# versions:
|
||||||
|
# - version: v1
|
||||||
|
# path: /api/v1
|
||||||
middleware:
|
middleware:
|
||||||
domain_env: SXCHNGE_DOMAIN
|
domain_env: SXCHNGE_DOMAIN
|
||||||
api_endpoint_env: SXCHNGE_API_ENDPOINT
|
api_endpoint_env: SXCHNGE_API_ENDPOINT
|
||||||
@@ -32,8 +43,5 @@ api:
|
|||||||
check:
|
check:
|
||||||
min_length: 12
|
min_length: 12
|
||||||
notifications:
|
notifications:
|
||||||
address: localhost:8443
|
address: localhost:8083
|
||||||
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
|
|
||||||
ca_file: ../notifications/tls/ca.pem
|
|
||||||
server_name: localhost
|
|
||||||
activation_url: http://localhost:5126/verify-email
|
activation_url: http://localhost:5126/verify-email
|
||||||
@@ -2,6 +2,10 @@ package accountserviceimp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||||
@@ -15,9 +19,6 @@ import (
|
|||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func customer(login string) *customermodel.Account {
|
func customer(login string) *customermodel.Account {
|
||||||
@@ -29,7 +30,7 @@ func customer(login string) *customermodel.Account {
|
|||||||
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
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)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||||
uri, err := container.ConnectionString(ctx)
|
uri, err := container.ConnectionString(ctx)
|
||||||
@@ -86,7 +87,10 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
|||||||
|
|
||||||
deps, err := edgemongo.ResolveDependencies(factory)
|
deps, err := edgemongo.ResolveDependencies(factory)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{})
|
raw, rawContext, release, err := deps.Database.Acquire(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer release()
|
||||||
|
collections, err := raw.ListCollectionNames(rawContext, bson.D{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
|
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
|
||||||
require.NotContains(t, collections, forbidden)
|
require.NotContains(t, collections, forbidden)
|
||||||
@@ -116,7 +120,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
|
|||||||
require.ErrorIs(t, err, outerFailure)
|
require.ErrorIs(t, err, outerFailure)
|
||||||
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
|
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
|
||||||
require.ErrorIs(t, err, merrors.ErrNoData)
|
require.ErrorIs(t, err, merrors.ErrNoData)
|
||||||
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
|
count, err := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.EqualValues(t, 2, count)
|
require.EqualValues(t, 2, count)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ func (s *service) CreateAccount(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
acct *customermodel.Account,
|
acct *customermodel.Account,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
return s.createAccount(ctx, acct, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateAccountWithActivationKey(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||||
|
if len(key) < 32 || len(key) > 128 {
|
||||||
|
return "", merrors.InvalidArgument("server activation key required")
|
||||||
|
}
|
||||||
|
return s.createAccount(ctx, acct, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) createAccount(ctx context.Context, acct *customermodel.Account, key string) (string, error) {
|
||||||
if acct == nil {
|
if acct == nil {
|
||||||
return "", merrors.InvalidArgument("account is required")
|
return "", merrors.InvalidArgument("account is required")
|
||||||
}
|
}
|
||||||
@@ -137,7 +148,7 @@ func (s *service) CreateAccount(
|
|||||||
if err := s.accountDB.Create(tx, saved); err != nil {
|
if err := s.accountDB.Create(tx, saved); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.VerifyAccount(tx, saved)
|
return s.vdb.Create(tx, verification.NewLinkRequest(saved.ID, verificationmodel.PurposeAccountActivation, "").WithTTL(24*time.Hour).WithIdempotencyKey(key))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
@@ -243,6 +254,13 @@ func NewAccountService(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &service{logger: logger, config: config, accountDB: stores.Accounts,
|
return FromStores(l, config, stores, dbf.TransactionFactory())
|
||||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
|
}
|
||||||
|
|
||||||
|
func FromStores(l mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (*service, error) {
|
||||||
|
if config == nil || stores == nil || transactions == nil {
|
||||||
|
return nil, merrors.InvalidArgument("customer dependencies are required")
|
||||||
|
}
|
||||||
|
return &service{logger: l.Named("account_service"), config: config, accountDB: stores.Accounts,
|
||||||
|
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: transactions}, nil
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package accountdb
|
package accountdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
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"
|
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"git.sendico.io/tech/sxchnge/shared/service"
|
"git.sendico.io/tech/sxchnge/shared/service"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ type AccountDB struct {
|
|||||||
template.DBImp[*customermodel.Account]
|
template.DBImp[*customermodel.Account]
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) {
|
func Create(logger mlogger.Logger, db source.Source) (*AccountDB, error) {
|
||||||
return CreateWithCollection(logger, db, mservice.Accounts)
|
return CreateWithCollection(logger, db, mservice.Accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) {
|
func CreateWithCollection(logger mlogger.Logger, db source.Source, collection string) (*AccountDB, error) {
|
||||||
if normalized := strings.TrimSpace(collection); normalized != "" {
|
if normalized := strings.TrimSpace(collection); normalized != "" {
|
||||||
collection = normalized
|
collection = normalized
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package accountservice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||||
|
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||||
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
||||||
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
"git.sendico.io/tech/sxchnge/shared/database"
|
"git.sendico.io/tech/sxchnge/shared/database"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
@@ -34,6 +36,9 @@ type AccountService interface {
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
acct *customermodel.Account,
|
acct *customermodel.Account,
|
||||||
) (verificationToken string, err error)
|
) (verificationToken string, err error)
|
||||||
|
// CreateAccountWithActivationKey preserves atomic creation and lets the auth
|
||||||
|
// service replay an unsent initial token. The key must be server-secret-derived.
|
||||||
|
CreateAccountWithActivationKey(context.Context, *customermodel.Account, string) (string, error)
|
||||||
|
|
||||||
VerifyAccount(
|
VerifyAccount(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -61,3 +66,8 @@ func NewAccountService(
|
|||||||
) (AccountService, error) {
|
) (AccountService, error) {
|
||||||
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FromStores composes a customer service using already initialized storage.
|
||||||
|
func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) {
|
||||||
|
return accountserviceimp.FromStores(logger, config, stores, transactions)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import "git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
|
||||||
|
type profileDTO struct {
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
DateOfBirth string `json:"dateOfBirth"`
|
||||||
|
ResidenceCountry string `json:"residenceCountry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p profileDTO) model() identity.Profile {
|
||||||
|
return identity.Profile{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry}
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationDTO struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
UpgradePending bool `json:"upgradePending"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
SubmissionID string `json:"submissionId,omitempty"`
|
||||||
|
Profile profileDTO `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapVerification(v identity.Verification) verificationDTO {
|
||||||
|
return verificationDTO{Status: v.Status, UpgradePending: v.UpgradePending, Reason: v.Reason, SubmissionID: v.SubmissionID, Profile: profileDTO{FullName: v.Profile.FullName, DateOfBirth: v.Profile.DateOfBirth, ResidenceCountry: v.Profile.ResidenceCountry}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitDTO struct {
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
|
Remaining string `json:"remaining"`
|
||||||
|
Used string `json:"used"`
|
||||||
|
Reserved string `json:"reserved"`
|
||||||
|
}
|
||||||
|
type limitsDTO struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
ProfileRef string `json:"profileRef"`
|
||||||
|
Entries []limitDTO `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLimits(v identity.Limits) limitsDTO {
|
||||||
|
entries := make([]limitDTO, 0, len(v.Entries))
|
||||||
|
for _, r := range v.Entries {
|
||||||
|
entries = append(entries, limitDTO{Operation: r.Operation, Period: r.Period, Currency: r.Currency, Cap: r.Cap, Remaining: r.Remaining, Used: r.Used, Reserved: r.Reserved})
|
||||||
|
}
|
||||||
|
return limitsDTO{Tier: v.Tier, ProfileRef: v.ProfileRef, Entries: entries}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package identityhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||||
|
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||||
|
"git.sendico.io/tech/sxchnge/product/identity"
|
||||||
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||||
|
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||||
|
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||||
|
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||||
|
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Registrar interface {
|
||||||
|
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||||
|
}
|
||||||
|
type router struct {
|
||||||
|
service *identity.Service
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(reg Registrar, service *identity.Service, logger mlogger.Logger) {
|
||||||
|
h := &router{service, logger.Named("identity_http")}
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/identity", api.Get, h.get)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/identity", api.Post, h.submit)
|
||||||
|
reg.AccountHandler(mservice.Accounts, "/limits", api.Get, h.limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *router) limits(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, err := h.service.Limits(r.Context(), a)
|
||||||
|
return h.result(mapLimits(v), err)
|
||||||
|
}
|
||||||
|
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||||
|
if err == nil {
|
||||||
|
return response.Ok(h.logger, data)
|
||||||
|
}
|
||||||
|
if throttle, ok := merrors.ThrottleDetailsFrom(err); ok {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
|
||||||
|
response.TooManyRequests(h.logger, mservice.Accounts, "Wait before retrying identity submission")(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check profile and use readable JPEG/PNG photos, 600×600 minimum, 10 MiB maximum each")
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return response.DataConflict(h.logger, mservice.Accounts, "Identity submission changed; refresh its status")
|
||||||
|
case errors.Is(err, merrors.ErrAccessDenied):
|
||||||
|
return response.AccessDenied(h.logger, mservice.Accounts, "Identity submission is unavailable")
|
||||||
|
default:
|
||||||
|
return response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Identity verification is temporarily unavailable; retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *router) get(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
v, err := h.service.Verification(r.Context(), a)
|
||||||
|
return h.result(mapVerification(v), err)
|
||||||
|
}
|
||||||
|
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||||
|
// Read multipart as a bounded stream. Never spill identity documents to disk.
|
||||||
|
r.Body = http.MaxBytesReader(nil, r.Body, 2*identity.MaxPhotoBytes+16384)
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("multipart required"))
|
||||||
|
}
|
||||||
|
fields := map[string][]byte{}
|
||||||
|
for {
|
||||||
|
part, readErr := reader.NextPart()
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("invalid multipart"))
|
||||||
|
}
|
||||||
|
name := part.FormName()
|
||||||
|
limit := int64(8192)
|
||||||
|
switch name {
|
||||||
|
case "document", "selfie":
|
||||||
|
limit = identity.MaxPhotoBytes
|
||||||
|
case "profile", "idempotencyKey":
|
||||||
|
default:
|
||||||
|
_ = part.Close()
|
||||||
|
return h.result(nil, merrors.InvalidArgument("unexpected field"))
|
||||||
|
}
|
||||||
|
if _, exists := fields[name]; exists {
|
||||||
|
_ = part.Close()
|
||||||
|
return h.result(nil, merrors.InvalidArgument("duplicate field"))
|
||||||
|
}
|
||||||
|
data, readErr := io.ReadAll(io.LimitReader(part, limit+1))
|
||||||
|
_ = part.Close()
|
||||||
|
if readErr != nil || int64(len(data)) > limit {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("field too large"))
|
||||||
|
}
|
||||||
|
fields[name] = data
|
||||||
|
}
|
||||||
|
var p profileDTO
|
||||||
|
if err = json.Unmarshal(fields["profile"], &p); err != nil {
|
||||||
|
return h.result(nil, merrors.InvalidArgument("profile required"))
|
||||||
|
}
|
||||||
|
v, err := h.service.SubmitVerification(r.Context(), a, string(fields["idempotencyKey"]), p.model(), fields["document"], fields["selfie"])
|
||||||
|
return h.result(mapVerification(v), err)
|
||||||
|
}
|
||||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user