Author SHA1 Message Date
tech 125a9e673e Merge pull request 'docs: generate code from externally published schemas' (#78) from codex/docs-external-schema into main 2026-11-02 15:09:54 +00:00
Stephan D 5a0b211d5f docs: generate code from externally published schemas 2026-11-02 16:09:42 +01:00
tech cd0981cea1 Merge pull request 'docs: require schema code generation at build time' (#77) from codex/docs-build-time-generation into main 2026-11-02 15:07:54 +00:00
Stephan D efeac88a49 docs: require schema code generation at build time 2026-11-02 16:07:39 +01:00
tech 3b551f1565 Merge pull request 'Set up RUB, USDT and USDC accounts automatically' (#73) from wallet-72 into main 2026-10-23 11:10:45 +00:00
Stephan D 645181c65c Record automatic currency account validation and delivery (#72) 2026-10-23 12:58:28 +02:00
tech de449d53bf Merge pull request 'Restore identity photo previews in Flutter Web' (#71) from kyc-69 into main 2026-10-23 10:51:08 +00:00
Stephan D fe60b7ac1a Record final photo preview contribution review (#69) 2026-10-23 12:50:43 +02:00
Stephan D b644a10663 Merge remote-tracking branch 'origin/main' into kyc-69 2026-10-23 12:46:56 +02:00
Stephan D 9370eacd9c Merge remote-tracking branch 'origin/main' into wallet-72 2026-10-23 12:43:55 +02:00
Stephan D 5c255296d3 Set up default currency accounts automatically (#72) 2026-10-23 12:43:55 +02:00
Stephan D c106706615 Record photo preview delivery PR (#69) 2026-10-23 12:33:52 +02:00
Stephan D 915b8e8123 Restore web identity photo previews (#69) 2026-10-23 12:33:28 +02:00
tech ec4115d083 Merge pull request 'Use dedicated day, month and year controls for birthdays' (#70) from kyc-67 into main 2026-10-23 10:32:05 +00:00
tech f3ff7fc016 Merge pull request 'Explain missing currency account setup' (#68) from wallet-66 into main 2026-10-23 10:31:58 +00:00
tech 658d715c8a Merge pull request 'Show accepted signup delivery without a second send step' (#63) from email-verification-62 into main 2026-10-23 10:31:43 +00:00
Stephan D 288a2f0c02 Record birthday control delivery PR (#67) 2026-10-23 12:20:01 +02:00
Stephan D 4605a197ad Use dedicated birthday entry controls (#67) 2026-10-23 12:19:24 +02:00
Stephan D 62df9eac5f Merge remote-tracking branch 'origin/main' into wallet-66 2026-10-23 12:17:10 +02:00
Stephan D 4b22f6ce12 Record empty-account message delivery PR (#66) 2026-10-23 12:13:40 +02:00
Stephan D b5c4772168 Explain missing currency account setup (#66) 2026-10-23 12:13:10 +02:00
tech 464bd2d565 Merge pull request 'Select residence country by searchable name' (#65) from kyc-64 into main 2026-10-23 10:12:13 +00:00
Stephan D f1ff40d072 Record country selector delivery PR (#64) 2026-10-23 11:48:34 +02:00
Stephan D 417e6d96b6 Select residence country by searchable name (#64) 2026-10-23 11:47:59 +02:00
Stephan D c8ef9c3d14 Record delivery PR for accepted signup state (#62) 2026-10-23 09:35:28 +02:00
Stephan D 3af60f38b1 Show accepted signup delivery without a second send step (#62) 2026-10-23 09:34:30 +02:00
tech e84d2d1ad3 Merge pull request 'Restore signup, email delivery and local recovery' (#59) from identity-facts-57 into main
Reviewed-on: #59
2026-10-20 18:00:23 +00:00
tech fa7b75d1b8 Merge pull request 'Complete verification links and prefill login with the verified email' (#61) from email-verification-60 into main
Reviewed-on: #61
2026-10-20 18:00:05 +00:00
Stephan D a587cbbc24 Record verification validation and local deployment (#60) 2026-10-19 14:54:53 +02:00
Stephan D 5f3b443e7b Complete verification links and preserve verified login email (#60) 2026-10-19 14:34:00 +02:00
tech 30f06c2f8f Merge pull request 'Enable Safari credential autofill for signup and login' (#58) from auth-56 into main 2026-10-19 11:02:42 +00:00
Stephan D 4c4b639321 Record private local signup request outcomes (#57) 2026-10-19 12:41:26 +02:00
Stephan D 13cc9c302e Recover local Sendico session after BFF network restarts (#57) 2026-10-19 12:34:37 +02:00
Stephan D 021bd04324 Reap orphaned frontend HTTPS probe processes (#57) 2026-10-18 13:54:30 +02:00
Stephan D 4844fb5bc9 Record verified local API recovery after Docker restart (#57) 2026-10-17 23:41:41 +02:00
Stephan D 3aeea9aa77 Record healthy SMTP deployment and passing validation (#57) 2026-10-17 23:38:10 +02:00
Stephan D 69fba8df70 Record SMTP deployment blocker and remaining restart (#57) 2026-10-17 21:17:39 +02:00
Stephan D b36eb72c23 Use Sendico SMTP mailbox for local notifications (#57) 2026-10-17 20:51:28 +02:00
Stephan D 3e4e496037 Log redacted email provider errors and diagnose sender configuration (#57) 2026-10-17 11:39:05 +02:00
Stephan D c1d28e705e Record signup fix delivery and recovery limitation 2026-10-17 07:30:50 +02:00
Stephan D abca15f047 Fix signup registration receipts and activation worker lifetime (#57) 2026-10-17 07:30:33 +02:00
Stephan D c6ab4680fd Record autofill validation and delivery link 2026-10-17 07:24:55 +02:00
Stephan D 86b6aafe7d Enable credential autofill for signup and login (#56) 2026-10-17 07:24:27 +02:00
93 changed files with 4309 additions and 201 deletions

No files matched your search

+9 -6
View File
@@ -13,14 +13,17 @@
- 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 ## Local development tooling
+6
View File
@@ -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`,
+54 -3
View File
@@ -12,12 +12,35 @@ 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
@@ -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
+1 -3
View File
@@ -45,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
@@ -57,7 +55,7 @@ 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
} }
+5 -1
View File
@@ -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 -1
View File
@@ -13,7 +13,7 @@ messaging:
reconnect_wait: 2 reconnect_wait: 2
templates: "templates.example.json" templates: "templates.example.json"
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"
+9
View File
@@ -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 {
+97
View File
@@ -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)
}
}
+17 -8
View File
@@ -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
}
+49
View File
@@ -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
}
+122
View File
@@ -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
}
+223
View File
@@ -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)
}
}
+19 -4
View File
@@ -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
} }
+42 -1
View File
@@ -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)
}
}
})
}
}
+1
View File
@@ -75,6 +75,7 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err return nil, nil, err
} }
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")} 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") logger.Info("Sendico sign-up facts integration enabled")
} }
records, err := factorstore.Open(logger, db) records, err := factorstore.Open(logger, db)
+27 -4
View File
@@ -31,8 +31,13 @@ type SignupFacts interface {
Ready(context.Context, bson.ObjectID) error Ready(context.Context, bson.ObjectID) error
} }
type CurrencyAccounts interface {
Ensure(context.Context, *customer.Account) error
}
type Service struct { type Service struct {
Facts SignupFacts Facts SignupFacts
Wallet CurrencyAccounts
Accounts account.DB Accounts account.DB
Customers accountservice.AccountService Customers accountservice.AccountService
Tokens verification.DB Tokens verification.DB
@@ -110,6 +115,11 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
return nil, err 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()
@@ -148,6 +158,11 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
return err 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
} }
@@ -179,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 {
@@ -200,7 +216,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
return nil, err return nil, err
} }
} }
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)) 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
} }
@@ -139,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))
@@ -75,6 +75,17 @@ 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()
@@ -102,6 +113,8 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
provider := &signupProvider{fail: true} provider := &signupProvider{fail: true}
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()} 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)
@@ -133,6 +146,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
signup["name"] = "Changed retry name" signup["name"] = "Changed retry name"
provider.fail = false provider.fail = false
w = post("signup", signup) 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, 1, provider.registrations)
require.Equal(t, "Customer", provider.last.FullName) require.Equal(t, "Customer", provider.last.FullName)
@@ -149,20 +170,37 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
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, 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)) 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} guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard service.Facts = guard
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code) 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) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.False(t, a.IsActive()) require.False(t, a.IsActive())
guard.block = false 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)
+50
View File
@@ -0,0 +1,50 @@
package payments
import (
"context"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.uber.org/zap"
)
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
_, err := s.ensureAccounts(ctx, a)
if err != nil {
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
}
return nil
}
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
started := time.Now()
defer func() {
outcome := "ready"
if result != nil {
outcome = "failed"
}
accountSetup.WithLabelValues(outcome).Inc()
accountSetupDuration.Observe(time.Since(started).Seconds())
}()
if s == nil || s.Identity == nil || s.Provider == nil {
return "", Unavailable
}
if a == nil || a.ID.IsZero() {
return "", NotFound
}
subject, result = s.Identity.Subject(ctx, a)
if result != nil {
return "", result
}
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
return "", result
}
return subject, nil
}
+28
View File
@@ -0,0 +1,28 @@
package payments
import (
"testing"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
a := &customer.Account{}
a.ID = bson.NewObjectID()
p := &provider{namespace: "provider"}
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
require.NoError(t, s.Ensure(t.Context(), a))
require.Equal(t, "subject", p.setupSubject)
snapshot, err := s.Fetch(t.Context(), a)
require.NoError(t, err)
require.Len(t, snapshot.Accounts, 1)
require.Equal(t, 2, p.setupCalls)
p.setupErr = Unavailable
_, err = s.Fetch(t.Context(), a)
require.ErrorIs(t, err, Unavailable)
require.Error(t, s.Ensure(t.Context(), a))
require.Error(t, s.Ensure(t.Context(), nil))
}
+1
View File
@@ -53,6 +53,7 @@ func (*Record) Collection() string { return "payment_quotes" }
type Provider interface { type Provider interface {
Namespace() string Namespace() string
EnsureAccounts(context.Context, string) error
Accounts(context.Context, string) ([]Account, error) Accounts(context.Context, string) ([]Account, error)
Quote(context.Context, string, Request, []Account) (Quote, error) Quote(context.Context, string, Request, []Account) (Quote, error)
Execute(context.Context, string, string) (RemoteOperation, error) Execute(context.Context, string, string) (RemoteOperation, error)
+1 -1
View File
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
if err := s.ready(); err != nil { if err := s.ready(); err != nil {
return Snapshot{}, err return Snapshot{}, err
} }
subject, err := s.Identity.Subject(ctx, a) subject, err := s.ensureAccounts(ctx, a)
if err != nil { if err != nil {
return Snapshot{}, err return Snapshot{}, err
} }
+10
View File
@@ -82,9 +82,19 @@ type provider struct {
status string status string
key string key string
namespace string namespace string
setupCalls int
setupSubject string
setupErr error
} }
func (p *provider) Namespace() string { return p.namespace } func (p *provider) Namespace() string { return p.namespace }
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.setupCalls++
p.setupSubject = subject
return p.setupErr
}
func (p *provider) Accounts(context.Context, string) ([]Account, error) { func (p *provider) Accounts(context.Context, string) ([]Account, error) {
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
} }
@@ -0,0 +1,87 @@
package sendico
import (
"context"
"net/http"
"git.sendico.io/tech/sxchnge/product/payments"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
}
// EnsureAccounts also repairs older customers, using provider-enforced
// organization/subject/currency uniqueness for safe retries after lost responses.
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
if !validRef(subject) {
return payments.Unavailable
}
root := "/ledger_accounts/" + c.config.OrganizationID
var data accountListDTO
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
return err
}
existing := map[string]bool{}
reconcile := map[string]bool{}
for _, a := range data.Accounts {
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
continue
}
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
return payments.Unavailable
}
existing[a.Currency] = true
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
}
missing := []string{}
for _, code := range []string{"RUB", "USDT", "USDC"} {
if !existing[code] || reconcile[code] {
missing = append(missing, code)
}
}
if len(missing) == 0 {
return nil
}
// Older providers can accept the metadata but cannot prevent duplicates.
// Require the explicit capability before issuing the first create.
if !data.ExternalSubjectAccounts {
return payments.Unavailable
}
var catalog struct {
Rows []currencyDTO `json:"currencies"`
}
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
return err
}
for _, code := range missing {
found := false
for _, currency := range catalog.Rows {
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
found = true
}
}
if !found {
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
}
}
// Replaying a bound create also resumes Sendico's durable wallet/route setup
// if a previous response failed after the ledger itself had been committed.
for _, code := range missing {
var response struct {
Account accountDTO `json:"account"`
}
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
return err
}
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
return payments.Unavailable
}
}
return nil
}
@@ -0,0 +1,200 @@
package sendico
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
type accountServer struct {
mu sync.Mutex
rows []accountDTO
posts []string
capable bool
missingCurrency bool
failCurrency string
loseResponse bool
wrongResponse bool
}
func customerLedger(code string) accountDTO {
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
}
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
require.NotEmpty(t, r.Header.Get("Authorization"))
var data any
switch {
case strings.Contains(r.URL.Path, "/currencies/"):
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
if s.missingCurrency {
rows = rows[:2]
}
data = map[string]any{"currencies": rows}
case r.Method == http.MethodPost:
var request createAccountDTO
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Equal(t, "asset", request.Type)
require.Equal(t, "operating", request.LedgerRole)
require.False(t, request.AllowNegative)
require.Equal(t, subject, request.Metadata["external_subject_ref"])
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
s.posts = append(s.posts, request.Currency)
if s.failCurrency == request.Currency {
s.failCurrency = ""
w.WriteHeader(503)
return
}
var row accountDTO
for _, a := range s.rows {
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
row = a
}
}
if row.ID == "" {
row = customerLedger(request.Currency)
s.rows = append(s.rows, row)
}
if s.loseResponse {
s.loseResponse = false
w.WriteHeader(503)
return
}
if s.wrongResponse {
row.Currency = "EUR"
}
w.WriteHeader(http.StatusCreated)
data = map[string]any{"account": row}
case strings.HasSuffix(r.URL.Path, "/balance"):
parts := strings.Split(r.URL.Path, "/")
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
default:
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
}
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
}
}
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
for _, lost := range []bool{false, true} {
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
if lost {
s.failCurrency = ""
}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.Error(t, c.EnsureAccounts(t.Context(), subject))
require.Len(t, s.rows, 1)
firstID := s.rows[0].ID
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
require.Len(t, s.rows, 3)
require.Equal(t, firstID, s.rows[0].ID)
// Bound creates are replayed to finish interrupted wallet/route setup;
// the provider must return the original ledger each time.
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
})
}
}
func countCurrency(rows []string, code string) int {
n := 0
for _, r := range rows {
if r == code {
n++
}
}
return n
}
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
s := &accountServer{capable: true}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
errors := make(chan error, 12)
for range 12 {
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
}
for range 12 {
require.NoError(t, <-errors)
}
require.Len(t, s.rows, 3)
}
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
t.Run(kind, func(t *testing.T) {
s := &accountServer{capable: true}
a := customerLedger("RUB")
switch kind {
case "old provider":
s.capable = false
case "missing currency":
s.missingCurrency = true
case "frozen":
a.Status = "ACCOUNT_STATUS_FROZEN"
s.rows = []accountDTO{a}
case "owned":
a.Owner = source
s.rows = []accountDTO{a}
case "negative":
a.AllowNegative = true
s.rows = []accountDTO{a}
case "duplicate":
s.rows = []accountDTO{a, customerLedger("RUB")}
case "wrong subject":
a.Metadata["sxchnge_subject_ref"] = source
s.rows = []accountDTO{a}
}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.Error(t, c.EnsureAccounts(t.Context(), subject))
require.Empty(t, s.posts)
})
}
}
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
s := &accountServer{capable: true, wrongResponse: true}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.Error(t, c.EnsureAccounts(t.Context(), subject))
require.Len(t, s.posts, 1)
}
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
a := customerLedger("RUB")
delete(a.Metadata, "external_subject_ref")
s := &accountServer{capable: true, rows: []accountDTO{a}}
server := httptest.NewServer(s.serve(t))
defer server.Close()
c, err := Open(config(t, server.URL))
require.NoError(t, err)
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
// A subsequent read uses the original RUB ledger and its authoritative funds.
s.rows = s.rows[:1]
accounts, err := c.Accounts(t.Context(), subject)
require.NoError(t, err)
require.Len(t, accounts, 1)
require.Equal(t, a.ID, accounts[0].ID)
require.EqualValues(t, 4500, accounts[0].Available)
}
@@ -0,0 +1,14 @@
package sendico
type accountListDTO struct {
Accounts []accountDTO `json:"accounts"`
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
}
type createAccountDTO struct {
Currency string `json:"currency"`
Type string `json:"accountType"`
LedgerRole string `json:"role"`
AllowNegative bool `json:"allowNegative"`
Metadata map[string]string `json:"metadata"`
}
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) { func (c *Client) post(ctx context.Context, resource, requestRef string, payload any, expectedStatus int) (receiptDTO, error) {
var receipt receiptDTO var receipt receiptDTO
token, err := c.token() token, err := c.token()
if err != nil { if err != nil {
@@ -38,7 +38,7 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
} }
defer func() { _ = response.Body.Close() }() defer func() { _ = response.Body.Close() }()
// Never decode or expose upstream error bodies, which can contain assertions. // Never decode or expose upstream error bodies, which can contain assertions.
if response.StatusCode != http.StatusOK { if response.StatusCode != expectedStatus {
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted") return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
} }
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385)) decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
@@ -54,14 +54,14 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
} }
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) { func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
dto, err := c.post(ctx, "individuals", requestRef, nil) dto, err := c.post(ctx, "individuals", requestRef, nil, http.StatusCreated)
if err != nil { if err != nil {
return "", err return "", err
} }
return mapSubject(dto) return mapSubject(dto)
} }
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) { func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts)) dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts), http.StatusOK)
if err != nil { if err != nil {
return identity.Receipt{}, err return identity.Receipt{}, err
} }
@@ -48,6 +48,7 @@ func TestSignupWireContractAndCredentialRotation(t *testing.T) {
if strings.Contains(r.URL.Path, "/individuals/") { if strings.Contains(r.URL.Path, "/individuals/") {
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID")) require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
require.Empty(t, body) require.Empty(t, body)
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject) _, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
} else { } else {
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body)) require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
@@ -95,6 +96,36 @@ func TestRejectInvalidConfiguration(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestSignupReceiptsRequireOperationStatus(t *testing.T) {
for _, status := range []int{200, 201, 202, 204, 206, 300, 403, 409, 503} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00Z","submission_ref":%q,"accepted_at":"2026-10-07T10:30:00Z"}}`, subject, submission)
}))
defer server.Close()
client, err := Open(config(t, server.URL))
require.NoError(t, err)
ref, err := client.Register(context.Background(), "registration-request")
if status == http.StatusCreated {
require.NoError(t, err)
require.Equal(t, subject, ref)
} else {
require.Error(t, err)
require.Empty(t, ref)
}
receipt, err := client.Submit(context.Background(), subject, "key", identity.Facts{})
if status == http.StatusOK {
require.NoError(t, err)
require.Equal(t, submission, receipt.SubmissionRef)
} else {
require.Error(t, err)
require.Empty(t, receipt)
}
})
}
}
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) { func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
@@ -113,7 +144,11 @@ func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status) status := tc.status
if status == http.StatusOK && strings.Contains(r.URL.Path, "/individuals/") {
status = http.StatusCreated
}
w.WriteHeader(status)
_, _ = io.WriteString(w, tc.body) _, _ = io.WriteString(w, tc.body)
})) }))
defer server.Close() defer server.Close()
@@ -27,9 +27,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
scales[row.Code] = *row.Scale scales[row.Code] = *row.Scale
} }
} }
var data struct { var data accountListDTO
Accounts []accountDTO `json:"accounts"`
}
root := "/ledger_accounts/" + c.config.OrganizationID root := "/ledger_accounts/" + c.config.OrganizationID
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil { if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
return nil, err return nil, err
@@ -39,7 +37,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
if row.Metadata["sxchnge_subject_ref"] != subject { if row.Metadata["sxchnge_subject_ref"] != subject {
continue continue
} }
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" { if !c.validCustomerAccount(row, subject) {
return nil, payments.Unavailable return nil, payments.Unavailable
} }
scale, ok := scales[row.Currency] scale, ok := scales[row.Currency]
@@ -61,6 +61,7 @@ type accountDTO struct {
Type string `json:"accountType"` Type string `json:"accountType"`
LedgerRole string `json:"role"` LedgerRole string `json:"role"`
Status string `json:"status"` Status string `json:"status"`
AllowNegative bool `json:"allowNegative"`
Metadata map[string]string `json:"metadata"` Metadata map[string]string `json:"metadata"`
} }
type currencyDTO struct { type currencyDTO struct {
@@ -72,7 +73,7 @@ type balanceDTO struct {
Money moneyDTO `json:"balance"` Money moneyDTO `json:"balance"`
} }
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error { func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any, accepted ...int) error {
token, err := c.token() token, err := c.token()
if err != nil { if err != nil {
return err return err
@@ -98,7 +99,11 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
} }
defer func() { _ = res.Body.Close() }() defer func() { _ = res.Body.Close() }()
// Never forward upstream errors, credentials, or arbitrary error bodies. // Never forward upstream errors, credentials, or arbitrary error bodies.
if res.StatusCode != http.StatusOK { expected := http.StatusOK
if len(accepted) != 0 {
expected = accepted[0]
}
if res.StatusCode != expected {
return payments.Unavailable return payments.Unavailable
} }
var envelope struct { var envelope struct {
+33 -17
View File
@@ -27,10 +27,17 @@ project directory. Dockerfiles are in `infra/images/`. Runtime and startup files
are grouped by service under `infra/local/`. The stack consists of Vault, Mongo, are grouped by service under `infra/local/`. The stack consists of Vault, Mongo,
NATS, notifications, BFF and Caddy serving the frontend. NATS, notifications, BFF and Caddy serving the frontend.
The frontend container uses Compose `init: true` to reap orphaned `ssl_client`
children created by BusyBox's HTTPS `wget` health probe. Keep this enabled:
Caddy alone does not reap those children when it runs as PID 1, and repeated
probes can accumulate thousands of zombie processes. Recreate the frontend
container when applying this setting; restarting an old container does not
change its init configuration.
On first startup, bootstrap initializes/unseals persistent Vault, provisions On first startup, bootstrap initializes/unseals persistent Vault, provisions
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
until Resend credentials exist. Infrastructure remains available for provisioning. until the SMTP mailbox password exists. Infrastructure remains available for provisioning.
After all services pass their startup health checks and the public Caddy CA is After all services pass their startup health checks and the public Caddy CA is
exported, `make up` prints a Sendico-style completion message with the frontend, exported, `make up` prints a Sendico-style completion message with the frontend,
@@ -56,7 +63,7 @@ kv/
├── services/ ├── services/
│ ├── bff/jwt # jwt │ ├── bff/jwt # jwt
│ └── notifications/ │ └── notifications/
│ └── email/resend # api_key only │ └── email/smtp # password only
└── infrastructure/ └── infrastructure/
├── nats/auth # token; read by NATS and notifications ├── nats/auth # token; read by NATS and notifications
└── mongo/bootstrap # local administrator and replica-set bootstrap material └── mongo/bootstrap # local administrator and replica-set bootstrap material
@@ -74,29 +81,35 @@ There are no frontend certificates, notification certificates or generic `tls`
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
on the trusted private Docker network without caller authentication. on the trusted private Docker network without caller authentication.
## Resend ## Local SMTP mail
Open [Vault](http://127.0.0.1:18200) and edit Open [Vault](http://127.0.0.1:18200) and edit
`kv/services/notifications/email/resend`: `kv/services/notifications/email/smtp`:
| Field | Value | | Field | Value |
| --- | --- | | --- | --- |
| `api_key` | Your Resend API key | | `password` | Password for the configured SMTP mailbox |
The driver and sender settings belong in `infra/local/notifications/runtime.yml`: The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
```yaml ```yaml
email: email:
driver: resend driver: smtp
settings: settings:
api_key_env: EMAIL_API_KEY host: mail.sendico.io
sender_address: "your-verified-sender@example.com" port: 465
username: no-reply@sendico.io
password_env: SMTP_PASSWORD
network_timeout: 10
sender_address: no-reply@sendico.io
sender_name: "SXCHNGE" sender_name: "SXCHNGE"
``` ```
Set your verified address in that file and rebuild. It is intentionally empty by The local stack uses Sendico's existing `no-reply@sendico.io` mailbox with
default, so notifications cannot start with an unconfigured sender. Vault Agent authenticated implicit TLS on port 465 and certificate verification enabled.
injects only the API key; it does not generate email configuration. Vault Agent injects only the password; host, username and sender are non-secret
runtime settings. Credentials are provisioned into SXCHNGE's own Vault; the
notification service does not read Sendico configuration or secrets at runtime.
Local administrator login material is in private `.local/dev/admin/init.json`. Local administrator login material is in private `.local/dev/admin/init.json`.
Product containers cannot access it. Alternatively use the hidden terminal prompt: Product containers cannot access it. Alternatively use the hidden terminal prompt:
@@ -106,12 +119,15 @@ make email
make build && make up make build && make up
``` ```
No placeholder key or delivery fallback is installed. Startup validates presence; No placeholder password or delivery fallback is installed. Startup validates
it does not send an email or validate domain ownership with Resend. Updating the configuration and secret presence; the SMTP connection is opened on demand.
API key causes Agent to restart notifications. Driver/sender changes require an Updating the password causes Agent to restart notifications. Driver/sender
image rebuild. Existing API keys survive bootstrap. Migration removes legacy changes require an image rebuild. Old Resend secrets remain in Vault history
driver/sender fields from the current Vault secret with a version-checked write; but are not used or readable by the notifications AppRole. HTTP providers remain
the preceding version is preserved in Vault history and the private archive. available for other deployments; switching one requires matching Agent/policy
and runtime configuration. SMTP has no provider idempotency guarantee: explicit
4xx rejections retry, 5xx rejections fail, and ambiguous transport outcomes require
operator reconciliation before another send.
## Browser HTTPS and internal connections ## Browser HTTPS and internal connections
+40 -2
View File
@@ -105,12 +105,35 @@ docker compose --project-directory . -f infra/local/compose.yml \
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico -f infra/local/sendico/compose.yml --env-file .local/dev/images.env restart sendico
``` ```
A BFF container restart can leave its session worker in the old shared network
namespace. The worker checks Vault DNS every ten seconds, with a five-second
lookup limit. Three consecutive failures log a credential-free recovery reason
and terminate the session process; Docker's restart policy then attaches it to
the BFF's current namespace. A successful lookup resets the counter, so transient
DNS failures do not immediately restart the worker.
Container recreation changes the BFF container ID as well as its namespace.
Bootstrap and credential-rotation tooling recreate BFF and its configured session
worker together. Compose also marks the session dependency for restart when BFF
is explicitly updated. For a manual forced recreation, select both services:
```sh
docker compose --project-directory . -f infra/local/compose.yml \
-f infra/local/sendico/compose.yml --env-file .local/dev/images.env \
up -d --no-deps --force-recreate --wait bff sendico
```
The parent process cleans up its bridge, DNS monitor and renewal delay on exit.
This recovery retains the configured provider URL, account and replay namespace.
`make test-dev` checks seven healthy services, verified browser TLS, the Vault `make test-dev` checks seven healthy services, verified browser TLS, the Vault
layout and least-privilege identities, Mongo transactions/lease rotation, and layout and least-privilege identities, Mongo transactions/lease rotation, and
authenticated Sendico currency and ledger reads through the same loopback authenticated Sendico currency and ledger reads through the same loopback
connection and token used by BFF. It sends no email and executes no payment. connection and token used by BFF. It sends no email and executes no payment.
An empty wallet is expected until dedicated subject-bound currency ledgers and Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
saved methods are provisioned as described in [payment provisioning](payments.md). ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
Deploy the provider prerequisite and grant ledger-create permission first.
Saved funding and payout methods remain separate from currency-account setup.
## Recovery ## Recovery
@@ -130,6 +153,21 @@ and the established CA. A subsequent verified TLS check must succeed.
## Acceptance and operator UX review ## Acceptance and operator UX review
Recovery follow-up for [task #57](https://git.sendico.io/tech/sxchnge/issues/57) /
[PR #59](https://git.sendico.io/tech/sxchnge/pulls/59), validated on 2026-10-19:
`make test-dev-tooling`, `make test-dev-rotation`, Compose configuration validation,
`sh infra/local/tools.sh verify` and `git diff --check` pass. A BFF-only restart
triggered automatic session restart and restored matching network namespaces;
paired recreation also finished healthy. Rotation observed three real Vault
pool replacements, an unchanged BFF process and 534 transaction-backed requests
with zero failures. The final check verifies all seven SXCHNGE services, browser
HTTPS, Vault/database boundaries and authenticated Sendico currency/ledger reads.
An earlier check failed because Sendico's ledger announcement connection was
closed; restoring Discovery and its service announcers in dependency order
recovered that existing stand. No Sendico source or financial data was changed,
and no email or payment was sent. This follow-up changes shell/Compose tooling;
it does not rerun the earlier full Go/Flutter suite for unchanged product source.
Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`, Validated on 2026-10-16: `make build`, `make up`, `make test-dev-tooling`,
`make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product `make test-dev-infra`, `make test-dev`, and `git diff --check` passed. Both product
sites, Sendico Backoffice and both API health endpoints returned HTTP 200; sites, Sendico Backoffice and both API health endpoints returned HTTP 200;
+36 -7
View File
@@ -52,22 +52,50 @@ coordinated deployment of the platform's financial services.
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
credentials or organization/source settings in Flutter. Provision the integration credentials or organization/source settings in Flutter. Provision the integration
account's organization membership, payment-create/read, ledger-account/balance account's organization membership, payment-create/read, ledger-account create/read,
read and the permissions needed to resolve its stored payment methods. Verify balance read and the permissions needed to resolve its stored payment methods. Verify
those permissions in the intended environment before enabling real operations. those permissions in the intended environment before enabling real operations.
Through Sendico's existing operator APIs, provision one dedicated unowned ### Automatic currency accounts (#72)
organization ledger per customer currency, with active `asset` / `operating`
semantics, no negative balance and metadata: The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
during signup, before sending the activation email. Resending activation retries
the same setup. Every authenticated wallet load also ensures the defaults, so
existing customers receive missing accounts automatically when they next open
their wallet. This is lazy backfill, not an eager scan of dormant customers.
Setup creates dedicated unowned organization ledgers through the public
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
semantics, zero initial funds, no negative balance and metadata:
| Ledger metadata | Meaning | | Ledger metadata | Meaning |
| --- | --- | | --- | --- |
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up | | `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method | | `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix | | `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route | | `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
These are financial-resource bindings maintained by trusted provisioning, not Existing customer ledgers and their balances are preserved, including manually
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
defaults are created; existing additional currencies remain visible. Repeated
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
fail instead of creating a replacement. All required currency precision entries
are checked against the provider catalog before issuing creates. Saved funding
and payout bindings remain separate from creation of the currency accounts.
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
any create requests. Older providers fail safely without creating accounts.
The acknowledgement assumes Ledger and Edge are deployed in that order; it
does not discover the version of a separately running Ledger. No new runtime
configuration or credentials are introduced. Existing deployments must give the
integration principal the ledger-create permission in addition to its reads.
These are financial-resource bindings maintained by server-side setup, not
client choices or new runtime configuration. Do not tag a pooled/integration-login client choices or new runtime configuration. Do not tag a pooled/integration-login
ledger as a customer ledger. Do not place full bank/card details or secret values ledger as a customer ledger. Do not place full bank/card details or secret values
in labels or metadata. The BFF checks subject, organization, account type, role, in labels or metadata. The BFF checks subject, organization, account type, role,
@@ -79,7 +107,8 @@ review action. Funding uses a quote for the provisioned stored source; it does
not mint money or treat demo top-ups as real deposits. Additional destinations, not mint money or treat demo top-ups as real deposits. Additional destinations,
recipient onboarding and other funding instructions remain separate features. recipient onboarding and other funding instructions remain separate features.
An empty set of provisioned ledgers is an empty wallet. Existing organization If setup reports a failure, the API returns an unavailable wallet rather than an
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
payments and pooled balances are never imported into customer history. This payments and pooled balances are never imported into customer history. This
integration exposes the durable operations initiated through this BFF, with live integration exposes the durable operations initiated through this BFF, with live
provider reconciliation; independent inbound deposits/history ingestion and provider reconciliation; independent inbound deposits/history ingestion and
+216 -4
View File
@@ -5,8 +5,191 @@ Task [#46](https://git.sendico.io/tech/sxchnge/issues/46), branch
customer creation, runtime, shared database source and Flutter error feedback. customer creation, runtime, shared database source and Flutter error feedback.
Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`. Delivery: [PR #47](https://git.sendico.io/tech/sxchnge/pulls/47), targeting `main`.
## Registration response correction
Follow-up [#57](https://git.sendico.io/tech/sxchnge/issues/57), branch
`identity-facts-57`, delivered in [PR #59](https://git.sendico.io/tech/sxchnge/pulls/59)
against `main` (implementation `abca15f`), corrects the registration success status
to HTTP 201 Created. The PR remains unmerged.
Sendico's individual creation handler returns 201; the original adapter and test
fixture incorrectly required 200. A successful registration therefore left a
pending customer with an unresolved claim and `signup_attention_required`.
The adapter now checks the exact status per operation before validating receipts:
201 for registration and 200 for Facts intake. Other statuses, including other
2xx responses, remain failures. Malformed receipts and uncertain registrations
still require the existing recovery rules.
On 2026-10-17, the affected local signup was reconciled using its registration
correlation in Sendico logs and an authenticated organization-scoped individual
GET. A conditional majority write bound that confirmed individual to the existing
local claim, preserving the saved facts, namespace and registration request.
The rebuilt BFF resumed the public activation-resend flow: Facts intake returned
a persisted receipt and the endpoint returned 202 after activation-email queue
acceptance. No replacement individual was registered. The account remains
pending until the customer confirms the email; queue acceptance does not prove
inbox delivery.
Recovery also exposed a notification worker lifetime issue: its NATS subscription
retained the 20-second startup context and was automatically unsubscribed after
that deadline. Resource provisioning now has its own bounded context, while the
subscription follows the service lifetime and closes at shutdown. A regression
test checks that releasing provisioning leaves the subscription context active,
and service cancellation still ends it.
The rebuilt worker processed the queued activation message. The live Resend
provider rejected it with `provider_authentication_failed` (HTTP 401 or 403),
leaving delivery in `failed` after one attempt. Later diagnostics below identify
the unverified sender domain as the actual blocker. Correct domain authorization
before activation resend with a new idempotency key. Credential rotation is not
required by that error; if a separate rotation is needed, use `make email` and
allow Vault Agent to refresh/restart the notification process. Do not replay the
failed notification as a success or mark the customer active on their behalf.
Validation passed: `GOFLAGS=-p=2 make test` (all backend race tests and canonical
frontend suites), `make lint-backend`, focused provider/identity race tests and
notification bus/app race tests. The HTTP 201 regression first failed against the
old adapter and passed after correction. An earlier full run hit a database
credential-rotation timing failure at `rotation_test.go:249` during the Docker
build; the isolated race-test retry and both subsequent full runs passed.
Local BFF and notifications images were rebuilt with fresh upstream manifests
and resolved stable digest pins; the BFF, session sidecar and notifications
services are healthy. NATS shows the worker still waiting for deliveries beyond
the startup deadline, with no pending or unacknowledged jobs. Live email delivery
is blocked by provider authorization as described above. No security scans were
requested or run. Unrelated notifications runtime settings are excluded from
the task commits.
### Provider diagnostics follow-up
The same task/PR adds transient provider diagnostics to the worker's existing
structured outcome log: `provider_status`, `provider_error` and
`provider_message`. The adapter extracts bounded Resend/SendGrid error fields and
redacts credentials, request values, addresses, URLs and token/code fragments.
Transport errors have a sanitized cause; invalid or oversized responses have a
safe fallback. Diagnostics remain outside stored notification models and public
gRPC responses. The outcome is logged even when its result write fails. See
the [notification logging contract](../backend/notifications/README.md).
Runtime inspection confirmed `email.driver: resend`, an API key in Vault, and
sender `noreply@sxchnge.com`. A live diagnostic notification used Resend's
documented `delivered+sxchnge-config@resend.dev` test mailbox, without retrying
the customer's email. The rebuilt worker logged HTTP 403,
`provider_error: validation_error`, and the sanitized message:
`The sxchnge.com domain is not verified. Please, add and verify your domain on [redacted-url]`.
The precise blocker is sender-domain verification, not missing local Resend
configuration. A sending-only key is appropriate for sending; its inability to
read Resend's logs API does not explain the original send failure. Verify the
configured domain in Resend (and its required DNS records), or explicitly choose
an already verified sender domain. No sender/domain/DNS setting was changed by
this follow-up; the customer's account remains pending email confirmation.
Follow-up validation passed: `GOFLAGS=-p=2 make test`, `make lint-backend`, and
`go -C backend/notifications test -race ./email ./service -count=1`. Coverage
includes Resend/SendGrid messages, malformed/trailing/oversized response bodies,
credential/content/code redaction, Unicode-safe truncation, sanitized transport
errors, and exactly one provider outcome log even when persistence fails. The
notifications image was rebuilt from fresh stable digest pins and deployed
healthy; the live test verified both the diagnostic log and the unchanged generic
gRPC failure response. No security scan or real customer email retry was run.
## Behavior and boundaries ## Behavior and boundaries
### Local mail client
The user subsequently selected Sendico's existing mailbox instead of Resend for
local notifications. The authoritative Sendico configuration resolves to
`no-reply@sendico.io`, `mail.sendico.io:465`, authenticated implicit TLS and a
10-second network timeout. SXCHNGE's owned `smtp` adapter consumes rendered email
models through the existing sender contract and queue; it does not import or
access Sendico business modules, databases or messaging. The password was copied
once, without printing it, from the running Sendico notification environment into
`services/notifications/email/smtp` in SXCHNGE Vault. Only `password` is stored
there; host, port, username, display name and sender remain in runtime YAML.
The SMTP adapter verifies certificates, authenticates before submission, sends
UTF-8 MIME alternatives, and requires the final DATA 250 reply for acceptance.
SMTP 4xx replies retry through the existing worker policy; 5xx replies fail;
ambiguous transport outcomes remain UNKNOWN and are not immediately resent.
Message-ID is correlation only, not a provider idempotency guarantee. Existing
failed Resend notifications are not replayed or rewritten. The unverified
`sxchnge.com` Resend domain is no longer the selected local delivery path.
Live verified-TLS SMTP authentication and NOOP succeeded with reply 250 using
the password from SXCHNGE Vault, without submitting email. SMTP adapter race
tests, `make lint-backend`, `make test-dev-tooling` and `make test-dev-infra`
pass, including canonical Vault layout, service permission isolation, database
credential issuance/renewal/revocation and transactions. The final
`GOFLAGS=-p=2 make test` passes all backend race tests and frontend suites.
Earlier attempts hit Docker archive-copy/reaper timeouts, Go proxy TLS failures
and stalled cache extraction/container startup. The user authorized restarting
Docker Desktop. After recovery, the normal Compose `build --pull notifications`
succeeded with the resolved stable digest pins; no checks were disabled. The
generator stage shares the existing BuildKit Go module/build caches and retains
the pinned tools and canonical Make generation command. A discarded fallback
compiler attempt also failed to access its checksum-state directory; it was not
used for the deployed artifact.
The matching Vault Agent policy/template were applied, and notifications was
recreated healthy. Its running image matches the rebuilt image
`sha256:d4cbc0cb9005e9cef9381abdf2a13f672104d160536ee6d77ac73a909c0a8638`,
and its startup log confirms `email_driver: smtp`. No customer email was sent or
failed Resend job replayed; SMTP authentication is not a recipient-delivery
check. Sendico's Vault sealed during the Docker restart and was unsealed with
its existing local key, without reinitialization or exposing credentials.
Its affected services were restarted in dependency order. The local Sendico
session sidecar shares the BFF network namespace, so it was restarted after the
BFF to restore Docker DNS and the loopback bridge. Final
`sh infra/local/tools.sh verify` passes: seven healthy services, verified HTTPS
for frontend/proxied BFF, authenticated Sendico currency/ledger reads and the
Vault/database checks. No Sendico repository files were changed.
On October 18, the frontend had 15,897 processes, including 15,888 unreaped
`ssl_client` zombie children of Caddy PID 1. The three-second HTTPS health
probe used BusyBox `wget`, which left those children behind. This confirmed a
recurring local infrastructure leak, independently of SMTP authentication.
Compose now enables `init: true` for frontend, and only that container was
recreated. PID 1 is now `docker-init`; 40 consecutive HTTPS probes completed
with zero zombie processes and a bounded count of 15 processes afterward.
Compose configuration validation and `make test-dev-tooling` pass. The API
health endpoint returns HTTP 200; a diagnostic request using the existing
account and a generated incorrect password returned HTTP 400 `email_in_use`
without creating an account or sending email.
The contemporaneous full local verification attempt is blocked by MongoDB's
five-second health-check timeout under continuing host load; it is not recorded
as passing. The exact response to the reported Safari attempt was not captured,
and the frontend's generic unavailable text also covers some HTTP 400 input
errors. A fresh user retry is needed to confirm the visible signup outcome.
On October 19 the failing API was directly observed returning HTTP 502. The BFF
had restarted while the session worker retained its old network namespace;
`/proc/self/ns/net` differed between them, and the worker could not resolve
Vault. Sendico's public API itself returned HTTP 200. Restarting only the worker
reattached its namespace and restored SXCHNGE API HTTP 200. This is a confirmed
cause of that 502, separate from the previous process leak and host-load finding.
The session now monitors bounded DNS failures and exits after three consecutive
failures so Docker can reattach it automatically. Bootstrap and rotation pair
BFF/session recreation, with Compose dependency restart enabled. Offline shell
fixtures cover transient-failure reset, sustained-failure recovery, safe parent
targeting and conditional service pairing. A live BFF-only restart automatically
restarted the session and restored matching namespaces, fresh session readiness
and API HTTP 200. A second live test recreated BFF and its session together and
confirmed healthy services with matching namespaces. No provider bindings,
customer records, credentials or Sendico source files were changed.
Final October 19 validation passes: `make test-dev-tooling`, Compose config
validation, `make test-dev-rotation` (three real rotations, unchanged BFF process,
534 transaction-backed requests and zero failures), and
`sh infra/local/tools.sh verify`. The last confirms all seven healthy services,
verified frontend/BFF HTTPS, Vault/database checks and authenticated Sendico
currency/ledger reads. An initial ledger-read failure was resolved by restoring
Sendico Discovery and its announcers in their documented dependency order after
closed NATS connections. No email was sent during validation. The recovery fix
and its validation are included in [PR #59](https://git.sendico.io/tech/sxchnge/pulls/59).
With `api.sendico` configured, the product BFF creates the local pending customer With `api.sendico` configured, the product BFF creates the local pending customer
and activation token atomically, then registers an independent individual through and activation token atomically, then registers an independent individual through
Sendico's public HTTP API and submits the sign-up assertions. It persists the Sendico's public HTTP API and submits the sign-up assertions. It persists the
@@ -18,7 +201,7 @@ backfilled or blocked at login.
The adapter consumes these existing Sendico contracts: The adapter consumes these existing Sendico contracts:
- `POST /individuals/{organization_ref}`, empty body, HTTP 200 registration - `POST /individuals/{organization_ref}`, empty body, HTTP 201 registration
receipt containing `subject_ref` and `registered_at`. receipt containing `subject_ref` and `registered_at`.
- `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt - `POST /identity_facts/{organization_ref}`, HTTP 200 immutable intake receipt
containing `submission_ref`, `subject_ref` and `accepted_at`. containing `submission_ref`, `subject_ref` and `accepted_at`.
@@ -73,9 +256,10 @@ the adapter rereads it on every request. Startup rejects missing, expired or
mismatched credentials and rejects Sendico configuration without notifications. mismatched credentials and rejects Sendico configuration without notifications.
HTTPS is required except for loopback HTTP test/development endpoints. Redirects HTTPS is required except for loopback HTTP test/development endpoints. Redirects
are rejected and each HTTP call has a four-second timeout. Non-200 responses, are rejected and each HTTP call has a four-second timeout. Unexpected response
malformed/truncated receipts, invalid identifiers, wrong subjects and unsuccessful statuses (registration requires 201; Facts intake requires 200), malformed/truncated
envelopes never count as acceptance. Upstream error bodies are not surfaced. receipts, invalid identifiers, wrong subjects and unsuccessful envelopes never
count as acceptance. Upstream error bodies are not surfaced.
Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates Omitting `api.sendico` keeps existing local-only behavior. Enabling it also gates
activation for previously pending customers; use activation resend to submit activation for previously pending customers; use activation resend to submit
@@ -85,6 +269,34 @@ adapter fingerprint and blocks existing bindings for explicit reconciliation.
Token rotation for the same account retains that fingerprint. Plan such changes Token rotation for the same account retains that fingerprint. Plan such changes
as migrations; never reset a binding just to bypass the check. as migrations; never reset a binding just to bypass the check.
## Local signup request diagnosis
The October 19 follow-up screenshot still shows the generic unavailable message
after the network recovery. That screenshot alone does not establish a 503:
the frontend also maps some rejected inputs and browser transport failures to
that message. At the follow-up check the health endpoint returned 200, and BFF
signup counters contained only the operator's two deliberately invalid probes.
The user's current browser failure has not yet been reproduced or resolved.
Local Caddy now logs requests to exactly `/api/v1/accounts/signup` under
`http.log.access.signup`, including HTTP status and duration. The complete
request object, response headers and user identifier are removed before log
encoding, so paths/queries, cookies, authorization, addresses and user-supplied
headers cannot enter these access records. Bodies are never logged. Other
requests, including health probes and email confirmation URLs, are skipped.
Inspect with:
```sh
docker logs --since 5m sxchnge-local-frontend-1 2>&1 |
jq -Rrc 'fromjson? | select(.logger=="http.log.access.signup")'
```
Validation: Caddy configuration validation and reload passed; a deliberately
invalid signup returned 400 and produced the expected status/duration record.
Sentinel query, cookie and custom-header values were absent from its log.
The frontend was rebuilt with freshly resolved stable image pins. No real
signup or customer email was submitted during this diagnostic work.
## Persistence, retries and recovery ## Persistence, retries and recovery
The local `signup_facts` collection uses the customer ID as `_id`, so its unique The local `signup_facts` collection uses the customer ID as `_id`, so its unique
+210
View File
@@ -149,3 +149,213 @@ text uses a normal text node to remain exposed to assistive technology.
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
`onboarding-12`. The final browser accessibility tree exposes the recipient text, `onboarding-12`. The final browser accessibility tree exposes the recipient text,
demo status, status heading and recovery actions. PR is open for review. demo status, status heading and recovery actions. PR is open for review.
## Browser credential autofill
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
commit `86b6aaf`. Open for review; no merge or deployment is included.
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
declares their purpose explicitly:
| Input | Flutter hints | Browser autocomplete |
| --- | --- | --- |
| Signup name | `name` with the name keyboard | `name` |
| Account email | `username`, then `email`, with the email keyboard | `username` |
| Login password | `password` | `current-password` |
| Signup password and confirmation | `newPassword` on both | `new-password` |
Flutter uses the first hint on web and Apple platforms. Email is the credential
username; the email keyboard remains configured separately. Visibility toggles
keep the password role, so autofill does not depend on detecting masked input.
The confirmation field participates in the same group as the first password,
allowing the browser to fill both without copying one controller into the other.
Signup acceptance finalizes autofill before clearing password controllers and
navigating to email verification. Login finalizes on success; when a second
factor is required, it waits for verification and login completion. The password
remains only in the existing form controller during that challenge, then clears
on completed navigation or cancellation. Invalid input, rejected credentials and
failed verification do not request saving. Switching auth mode, abandoning the
form or leaving for unverified-account recovery cancels the autofill context.
Provider/service/model boundaries and existing request/outcome telemetry are
unchanged; no field events or credential telemetry are added. Browser popup
display and pre-submit autofill abandonment remain outside current measurement.
### Verification and release check
Focused widget tests send a platform autofill response with all four signup
values and check EN/RU controller updates, grouped credential metadata, visibility
changes, mode switching and abandonment. Auth tests cover validation failures,
service rejection/retry, stale responses, signup/login acceptance, unverified
recovery, invalid second-factor codes and cancellation without premature saving.
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
Native Safari verification remains a release check: computer-control permission
was unavailable in this session. On the deployed HTTPS origin with Safari
AutoFill enabled, focus the signup email and password fields, choose a suggested
strong password, verify that both password fields populate, then confirm that
login offers the saved credential. Repeat after showing/hiding passwords and
switching modes. Browser settings, stored credentials and browser heuristics
determine whether suggestions appear; automated Flutter tests cannot establish
that the system popup was displayed or that credentials were saved.
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
`make test-frontend` passes all 212 tests across the app and shared packages.
`flutter build web` passes. The built page's default rendering path was inspected
in the in-app browser: login exposes `username`/`current-password` in one HTML
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
This verifies the browser metadata, not Safari's credential popup. With Flutter
web accessibility semantics enabled, unfocused semantic inputs initially use
framework-default autocomplete values until focused; password-manager behavior
with assistive technology still requires a native browser audit.
## Verification links and login handoff
Task: [#60](https://git.sendico.io/tech/sxchnge/issues/60), branch
`email-verification-60`. Email links use `/verify-email?token=…`; the web entry
point installs Flutter's path URL strategy before initializing the application.
This matches Caddy's existing SPA fallback and preserves the path and query on a
fresh page load. Previously the default hash strategy could start at login and
never submit the activation token. Unknown routes retain the canonical login
fallback; invalid, expired and failed confirmation attempts remain on the
verification page with the existing recovery actions.
Activation consumes the token, checks the pending account and accepted signup
facts, and activates the account in the existing database transaction. Only
after a successful commit does the API return `{verified: true, email: ...}`.
Failure responses do not disclose the account email. Responses retain the
shared no-store policy. The frontend backend DTO and mapper produce a
`VerificationResult`; the verification provider exposes that result only in the
verified state and clears it on reset, loading and failure. A verified response
from an older backend without the optional email can still show success.
Success stays visible until the user chooses **Continue to login**. This replaces
the misleading workspace action that could redirect an unauthenticated visitor
back to login. The verified email travels through transient router extras into
the editable login controller; the password starts empty and receives focus.
Normal password and second-factor checks remain required. The email is not
added to URLs, serialized history, persistent storage or analytics. A link opened
in another tab or device can receive the verified address from the API without
depending on the originating signup form's memory.
Existing requested/succeeded/failed email-confirmation journey signals remain
the measurement source. Success continues to mean that the confirmation service
returned a verified result, with simulated outcomes kept separate. No field or
prefill event is needed. Existing limits around pre-submit abandonment,
cross-device journey correlation and the in-memory analytics sink still apply.
Regression coverage includes transactional activation and rollback, returned
email and failure privacy, used-token rejection, backend response mapping,
provider identity clearing, EN/RU success-to-login navigation, editable prefill,
empty focused password, and exclusion of email from serialized navigation state.
The supplied private diagnostic token is not a test fixture or documentation
input; it is kept out of repository sources and tracker records.
### Validation and local release (#60)
Delivery: [PR #61](https://git.sendico.io/tech/sxchnge/pulls/61), implementation
commit `5f3b443`. Reviewed the complete backend and frontend contribution against
their module guidelines and the UX/observability checklists. No task-caused
review findings remain. Existing recovery, loading and accessible status states
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
pass. Browser startup was checked against a separately built demo with synthetic
tokens: direct success remains visible, Continue to login shows the verified
email with an empty focused password, and an expired link offers resend/back.
Native Safari was unavailable to automation, so browser-specific helper behavior
is not claimed as verified by this check.
On 2026-10-19, changed-file Dart formatting, `flutter analyze` in both affected
packages (`frontend/app`, `frontend/packages/sxchnge_auth`), `make test-frontend`
(217 tests), `GOFLAGS=-p=1 make test`, and `make lint-backend` passed. The focused
transactional activation integration test also passed independently. Initial
test-container startup and image-resolution attempts failed while Docker was
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
passed; no assertions, timeouts or gates were weakened. No security scan was
requested or run.
The task branch was integrated locally into the existing `identity-facts-57`
stand checkout, preserving its unrelated configuration. Fresh stable image
versions/digests were resolved successfully with `sh infra/local/tools.sh images`.
Compose rebuilt BFF and frontend with `build --pull bff frontend`, then recreated
them and the Sendico session sidecar (which shares BFF's network namespace).
Docker restart recovery also unsealed Sendico's existing Vault with its saved
local key and restarted affected existing services in dependency order.
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
the served JavaScript matches the new frontend image with SHA-256
`b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21`.
A live synthetic invalid token returns HTTP 400 without an email. The supplied
real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main
branch merge.
### Accepted signup delivery (#62)
Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch
`email-verification-62`, delivered in
[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup
returned HTTP 202 and the local
SMTP provider accepted its message with status 250, while the user reported an
internal-error screen immediately after Create account. No subsequent
verification request from that attempt reached the BFF. The precise original
client exception was not captured; an ordinary isolated release reproduction
succeeded. The regression below deliberately injects an unavailable follow-up
send service to cover the post-acceptance failure path without claiming that
injected exception was the original cause.
Backend and preview signup mappers now return a `VerificationDelivery` domain
receipt with the recipient and simulation status. It means that signup accepted
the email-queue operation, not inbox delivery, account activation or a session.
The signup provider exposes it through its operation result. The form captures
the receipt before clearing password controllers, whose change listeners reset
auth state, and passes it as transient router extra. The verification provider
initializes directly in the sent state; mounting the page performs no send.
This replaces the backend adapter's hidden one-use signup cache. Every explicit
resend now invokes its service, including the first one after signup.
Routes without a receipt retain their existing behavior: unverified login may
request an email, a fresh verification page asks for an address, and emailed
links run confirmation. Recipient changes invalidate the displayed claim until
another send succeeds. New receipts replace stale recipients. The route codec
discards the receipt when serializing history, so reloads do not invent a queued
result or retain the address. No token or password is included in the receipt.
The existing email-delivery requested/succeeded signals now follow the accepted
signup receipt inside the instrumented service instead of a synthetic send on
page mount. They stay in the signup journey, occur once for the accepted
operation, and remain intermediate queue-acceptance signals. Resends retain
separate attempts and failures; confirmation remains the terminal outcome.
Simulation and privacy rules are unchanged, including the in-memory analytics
and cross-device correlation limitations above.
Frontend review covered the complete app/auth contribution and preserved the
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
accepted signup followed by unavailable resend, explicit retry, normalized
receipt mapping, provider initialization/reset, recipient replacement, transient
history, event correlation and exclusion of form values from analytics. EN/RU
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
accessibility suites also pass. Changed-file Dart format checks, analysis in
`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend`
(221 tests) pass. The new injected-failure regression failed on the previous
implementation and passes with the receipt handoff. A release-browser fixture
with the same backend adapter and a failing resend service shows Check your
inbox after signup with no send call, then the expected error only after an
explicit resend. It sends no email. Native Safari was not automated.
The frontend image was rebuilt with freshly resolved stable digest pins and
recreated without restarting BFF. The served JavaScript matches the new image
at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`.
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
`sh infra/local/tools.sh verify` passes all seven services, Vault/database
checks and authenticated Sendico currency/ledger reads. No new customer email,
activation or payment was submitted for this change, and no security scan was
requested or run. The user's pre-existing browser tab must reload to use the
new bundle; their original Safari exception remains unobserved.
+49
View File
@@ -302,6 +302,55 @@ that contradicts displayed balances or usage without explaining the fixtures.
- Keep unrelated accounts/history accessible during a pending operation. Do not - Keep unrelated accounts/history accessible during a pending operation. Do not
block the whole product behind an exchange spinner. block the whole product behind an exchange spinner.
## Automatic currency accounts (task #72)
New customers receive RUB, USDT and USDC accounts through backend-owned setup
during signup. Existing customers receive missing defaults on their next wallet
load. The UI requires no account-creation form or support contact. During initial
loading it announces that the accounts are being prepared. Failure shows one
clear load error and a **Try again** action that reruns the same idempotent setup.
An unexpected empty snapshot has its own setup-failure fallback, rather than
being shown simultaneously with an error. A failed refresh retains existing
cards and the stale-data warning. Account-not-found copy requires loaded data.
The existing DTO → mapper → service → model → provider → UI boundaries remain
intact. Account creation belongs to the backend/provider, and widgets invoke the
existing provider refresh action. English and Russian use wrapping text and the
existing accessible button pattern; status text uses a live region. Dedicated
widget regressions cover loading, failure, retry and successful default accounts
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
screen-reader QA remain a release check; tests do not claim physical-device QA.
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
`ready` is emitted only after all default bindings are confirmed, including
idempotent completion of provider wallet/route setup for newly bound ledgers.
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
customer conversion. The failure fraction uses all attempts as its denominator;
the histogram measures one attempt, not time across multiple browser sessions.
No customer, ledger, currency, balance, credentials or form input is a metric
label. These server metrics do not measure client abandonment or prove that a
balance card was viewed. Existing transaction journey outcomes below are reused;
no speculative button-click events are added.
Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch
`wallet-72`, [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The provider
prerequisite and deployment order are documented in [payments](../payments.md).
Merge and deployment remain separate. Final validation is recorded in the task
and PR, including any transient failures and environmental blockers.
Final validation (2026-10-23): `flutter gen-l10n`, `flutter analyze` in
`frontend/app`, the changed-Dart formatting check, `make lint-backend`, and
`git diff --check` passed. `make test-frontend` passed all 242 tests on the current
app base, including the eight setup recovery scenarios. The full `make test`
passed with `TESTCONTAINERS_RYUK_DISABLED=true GOFLAGS='-p=2'`: earlier attempts
hit dependency DNS and Mongo/cleanup-helper startup timeouts. Disabling only the
failing helper retained every integration assertion, test timeout and race check;
each test's existing container cleanup still ran. No security scan was requested.
The live local provider catalog confirms RUB scale 2 and USDT/USDC scale 6.
Provider-side concurrency and frozen-binding checks passed against a disposable
Mongo replica set; see [Sendico PR #2970](https://git.sendico.io/tech/sendico/pulls/2970).
## Journey measurement plan ## Journey measurement plan
Reuse the typed analytics abstraction and maintain a separate journey per active Reuse the typed analytics abstraction and maintain a separate journey per active
+92
View File
@@ -167,3 +167,95 @@ This is a display correction within the existing KYC journey, so no additional
analytics events or financial data collection are introduced. Tests cover mapping analytics events or financial data collection are introduced. Tests cover mapping
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
usage display at 320px with 1.6 text scaling. usage display at 320px with 1.6 text scaling.
## Residence country selection — task #64
Task [#64](https://git.sendico.io/tech/sxchnge/issues/64), branch `kyc-64`.
Delivery: [PR #65](https://git.sendico.io/tech/sxchnge/pulls/65).
The profile now shows a country name and opens a searchable country list instead
of asking customers to type a two-letter code. Names follow the English/Russian
app locale. Search accepts localized or English names and two-letter codes,
ignoring case and surrounding whitespace. Selection updates only
`IdentityProfile.residenceCountry` with the country code; the existing provider,
service, mapper and transport contracts remain unchanged.
The `country_picker` dependency supplies bundled country data, code lookup and
translations through `CountryService` and `CountryLocalizations`. Flutter Material
controls provide the search interface, explicit no-results recovery, minimum
touch targets, keyboard selection, a selected-state checkmark, Back and Escape.
The package's built-in list omits explicit no-results feedback and uses shorter
row targets, so its data/localizations are reused with standard Material controls.
There is no remote lookup, copied country table or extra application provider.
Cancelling preserves the current profile, prefilled codes display names, busy
state disables opening the selector, and the search controller is disposed with
its dialog. The list is a data catalogue; backend eligibility remains authoritative.
This change stays within the existing KYC preparation/submission journey and its
measurement definitions above. No selector events, queries, names or country
codes are added to telemetry. Existing acceptance/failure/retry/abandonment
signals and documented server-side review measurement gaps still apply.
Focused widget checks cover name/code search, code-only updates, reselection,
cancel, no-results recovery, clearing search, saved values, locale changes,
Russian names, disabled state, keyboard activation/selection/Escape and labeled
touch targets. A long country name fits at 320px with 1.6 text scale. Existing
EN/RU verification journeys now select a country and assert the profile code
before continuing through document submission. Native screen-reader and device
keyboard testing remain release checks; widget semantics checks do not establish
those device outcomes.
Validation: five changed Dart files passed the format check; `flutter analyze`
in `frontend/app` reported no issues; `make test-frontend` passed all 222 tests;
and `flutter build web` passed, including its Wasm compatibility dry run. Browser
smoke testing on the local demo opened the selector, searched for Poland and
confirmed that the profile displays Poland after selection. Focused and full
journey tests verify the stored `PL` code. Generated localization/build outputs
remain ignored and uncommitted.
## Birthday entry — task #67
Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`.
Delivery: [PR #70](https://git.sendico.io/tech/sxchnge/pulls/70).
This change is stacked on the residence-country selector in PR #65; merge #65
before the birthday PR.
The profile uses labeled Day, Month and Year controls. Day and year accept numeric
entry with birthday autofill hints; month selection uses localized month names
and standard Material keyboard/touch interaction. Customers can enter their birth
year directly. No calendar paging or transport-format punctuation is required.
The controls share a row when space allows and stack at narrow widths or enlarged
text sizes. English and Russian labels, month names and errors follow the app locale.
Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth`
`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible
and future dates clear that profile value so Continue cannot retain an earlier
valid birthday. Local input components remain visible while correcting a date;
complete invalid dates receive a specific live-region error below the group.
Leap years, zero day/year and calendar rollover are checked. Today's date follows
the existing model policy; no minimum age or arbitrary oldest birth year is added.
Prefilled dates, locale changes and external profile replacement synchronize the
controls. Back navigation restores the valid saved date. Busy state disables all
three controls. Controllers are disposed with the widget.
The existing DTO, mapper, service, domain model and provider boundaries are retained.
No dependency, transport or backend change is needed. This remains part of the KYC
preparation-to-submission journey defined above: existing acceptance, failure,
retry and abandonment signals apply, without new field events or date collection.
The existing server-side review measurement gaps remain unchanged.
UX review and automated checks cover entry/correction, leap dates, invalid and
future-date recovery, numeric restrictions, prefill/replacement/clearing, locale
switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation,
and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size
tap-target guidelines pass. Integration checks verify that invalid dates block
Continue, Back preserves the birthday, and EN/RU flows still reach submission
acceptance. Native screen-reader and mobile keyboard/autofill testing remain
release checks; widget semantics are not a claim of device-level validation.
Validation: all four changed Dart files pass the format check; `flutter analyze`
in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests,
including the seven new birthday widget tests and the new navigation/gating test.
`flutter build web` passes, including its Wasm compatibility dry run. Browser
smoke testing of the release build entered 23 April 1990, verified the inline
error for 31 April and restored 23 April without losing month or year.
Generated localization and build outputs remain ignored and uncommitted.
+98
View File
@@ -0,0 +1,98 @@
# Identity photo preview correction
Task [#69](https://git.sendico.io/tech/sxchnge/issues/69), branch `kyc-69`.
Delivery: [PR #71](https://git.sendico.io/tech/sxchnge/pulls/71).
This frontend change builds on the [identity verification journey](kyc.md)
and is independent of the birthday controls in task #67.
## Cause and correction
A synthetic valid PNG reproduced this Flutter 3.47.7 browser error:
```text
TypeError: Instance of '_UnmodifiableUint8ListView': type
'_UnmodifiableUint8ListView' is not a subtype of type 'NativeUint8List'
```
The stack reached `Uint8ListToJSUint8Array.toJS` from CanvasKit's browser
image decoder through `MemoryImage` and `ResizeImage`. File selection had
successfully decoded the original typed bytes. The `Photo` constructor then
copied them into an unmodifiable view, which was passed directly into the
preview's web decoder. Native decoding accepted that view, hiding the bug in
native-only tests. The generic image error builder presented the failure as a
damaged document even though the selected bytes were valid.
`PhotoPreview` now creates a native `Uint8List` copy for `MemoryImage` once per
selected photo. The shared domain model stays immutable, and validation,
providers, services and transport contracts remain unchanged. The preview still
requests a decode width of at most 1000 pixels and evicts its decoded cache entry
on replacement or disposal. The extra encoded-byte copy belongs to the preview
and is released with its provider/cache references; no secure-erasure guarantee
is made.
An actual preview failure now says that the preview could not be displayed and
suggests selecting the photo again. The English/Russian fallback keeps at least
the preview's usual height, can grow for long text, and has a separate live-region
semantic node. Existing replacement/removal controls stay available and selected
input is retained. This is distinct from validation's corrupt-file message.
## UX and measurement review
The outcome remains selecting readable ID and selfie photos, checking both on
review, and submitting for confirmed service acceptance. Existing KYC journey
attempts, retries, failure, submission and abandonment signals are reused. No
control-level events, image bytes, filenames, form values or raw exceptions are
added to telemetry. A selection success measures validation; it does not establish
that pixels were painted. Preview-only rendering failures are not separately
counted by existing telemetry. Server review/approval measurement and native
screen-reader QA retain the limitations documented in the original journey.
Regression tests use synthetic JPEG/PNG content only. They start and await real
image decoding outside the widget test's fake async zone, inspect the actual
rendered images, and surface stream errors so an error fallback cannot conceal a
failure. Checks cover portrait/landscape decoding, model immutability, bounded
decode width, stable provider reuse, replacement/removal cache eviction, both
review slots, returning to review, localized fallback/replacement, a 320px view
at 1.6 text scale, and error announcements isolated from surrounding instructions.
The `image` package is already a KYC runtime dependency; its direct app test
dependency is used only to create synthetic fixtures in memory.
## Validation and delivery
The original browser regression failed before the fix while its native equivalent
passed. The complete task diff was reviewed against frontend architecture, UX,
measurement, lifecycle and privacy requirements. Final validation passed:
- In `frontend/app`, `dart format --output=none --set-exit-if-changed
lib/kyc/preview.dart test/preview_test.dart`: two files, no changes.
- In `frontend/app`, `flutter analyze --no-pub`: no issues.
- In `frontend/app`, `flutter test --platform chrome test/preview_test.dart
--reporter expanded --no-pub --verbose`: all six regression tests passed.
- At the repository root, `make test-frontend`: all 228 tests passed across
the shared packages, theme example and app, including the six native preview
regressions and existing KYC journey/analytics tests.
- In `frontend/app`, `flutter build web --no-pub`: release build and Wasm
compatibility dry run succeeded.
Browser regression coverage uses Chrome; Safari and native assistive-technology
testing remain release checks. Generated localization and build code stays
ignored; the tracked app lockfile changes only `image` from transitive to direct
test dependency. The separate birthday worktree was preserved.
## Follow-up contribution review
The complete seven-file contribution was reviewed again before the authorized
merge and frontend rollout. No additional defects were found in the byte-copy
boundary, provider reuse, cache disposal, error semantics/localization, privacy,
architecture or regression coverage. No source changes were needed.
Current `main` (`ec4115d`, including the birthday, signup-delivery and account
setup changes) was merged into `kyc-69` before final verification. The two-file
format check and app analyzer passed; all six Chrome preview regressions and
all 240 tests in `make test-frontend` passed on the combined code. The original
228-test result above describes the earlier isolated branch.
The authorized rollout rebuilds only `frontend` from clean, pulled `main`, using
the repository's fresh stable-release resolver and digest-pinned local inputs.
Runtime image, HTTPS and served-bundle verification are recorded in task #69
after the rollout; generated local pins remain ignored.
+39 -7
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -18,7 +19,8 @@ import 'validation.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget { class AuthForm extends StatefulWidget {
const AuthForm({super.key}); const AuthForm({super.key, this.email});
final String? email;
@override @override
State<AuthForm> createState() => _AuthFormState(); State<AuthForm> createState() => _AuthFormState();
@@ -32,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
bool _showPassword = false; bool _showPassword = false;
bool _showConfirmation = false; bool _showConfirmation = false;
bool _submitted = false; bool _submitted = false;
final _email = TextEditingController(); late final _email = TextEditingController(text: widget.email);
final _name = TextEditingController(); final _name = TextEditingController();
AuthProvider get _provider => AuthProvider get _provider =>
@@ -63,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) { void _switch(bool signUp) {
if (signUp == _signUp) return; if (signUp == _signUp) return;
FocusScope.of(context).unfocus();
TextInput.finishAutofillContext(shouldSave: false);
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange); context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset(); context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset(); context.read<SignupProvider>().reset();
@@ -95,9 +99,11 @@ class _AuthFormState extends State<AuthForm> {
return; return;
} }
final email = _email.text.trim(); final email = _email.text.trim();
final delivery = signup.state.result!.delivery;
TextInput.finishAutofillContext();
_password.clear(); _password.clear();
_confirmation.clear(); _confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email); context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
} else { } else {
final login = context.read<LoginProvider>(); final login = context.read<LoginProvider>();
await login.login( await login.login(
@@ -107,13 +113,13 @@ class _AuthFormState extends State<AuthForm> {
); );
if (!mounted || _signUp) return; if (!mounted || _signUp) return;
if (login.state.error == AuthError.accountNotVerified) { if (login.state.error == AuthError.accountNotVerified) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear(); _password.clear();
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim()); context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
return; return;
} }
final challenge = login.state.result?.challenge; final challenge = login.state.result?.challenge;
if (challenge != null) { if (challenge != null) {
_password.clear();
final grant = await verifyFactor( final grant = await verifyFactor(
context, context,
challenge: challenge, challenge: challenge,
@@ -123,6 +129,8 @@ class _AuthFormState extends State<AuthForm> {
); );
if (!mounted || _signUp) return; if (!mounted || _signUp) return;
if (grant == null) { if (grant == null) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
login.reset(); login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation); context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return; return;
@@ -137,9 +145,13 @@ class _AuthFormState extends State<AuthForm> {
!_signUp && !_signUp &&
identical(login.state.result, accepted) && identical(login.state.result, accepted) &&
login.state.status == AuthStatus.success) { login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
_password.clear();
context.go(offer ? '/identity?onboarding=1' : '/accounts'); context.go(offer ? '/identity?onboarding=1' : '/accounts');
} }
} }
} else if (login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
} }
} }
} }
@@ -195,7 +207,10 @@ class _AuthFormState extends State<AuthForm> {
expandedInsets: EdgeInsets.zero, expandedInsets: EdgeInsets.zero,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Form( AutofillGroup(
// Leaving an unfinished form must not offer to save its credentials.
onDisposeAction: AutofillContextAction.cancel,
child: Form(
key: _form, key: _form,
autovalidateMode: _submitted autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction ? AutovalidateMode.onUserInteraction
@@ -220,6 +235,8 @@ class _AuthFormState extends State<AuthForm> {
errorMaxLines: 3, errorMaxLines: 3,
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
keyboardType: TextInputType.name,
autofillHints: const [AutofillHints.name],
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l), validator: (value) => AuthValidation.name(value, l),
), ),
@@ -236,6 +253,10 @@ class _AuthFormState extends State<AuthForm> {
errorMaxLines: 3, errorMaxLines: 3,
), ),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autocorrect: false, autocorrect: false,
validator: (value) => AuthValidation.email(value, l), validator: (value) => AuthValidation.email(value, l),
@@ -245,7 +266,13 @@ class _AuthFormState extends State<AuthForm> {
key: const ValueKey('password'), key: const ValueKey('password'),
enabled: !state.isLoading, enabled: !state.isLoading,
controller: _password, controller: _password,
autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword, obscureText: !_showPassword,
autofillHints: [
_signUp
? AutofillHints.newPassword
: AutofillHints.password,
],
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: _signUp textInputAction: _signUp
@@ -281,6 +308,7 @@ class _AuthFormState extends State<AuthForm> {
enabled: !state.isLoading, enabled: !state.isLoading,
controller: _confirmation, controller: _confirmation,
obscureText: !_showConfirmation, obscureText: !_showConfirmation,
autofillHints: const [AutofillHints.newPassword],
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
@@ -310,7 +338,8 @@ class _AuthFormState extends State<AuthForm> {
const SizedBox(height: 28), const SizedBox(height: 28),
FilledButton( FilledButton(
key: const ValueKey('submit'), key: const ValueKey('submit'),
onPressed: state.isLoading || state.status == AuthStatus.success onPressed:
state.isLoading || state.status == AuthStatus.success
? null ? null
: _submit, : _submit,
child: Padding( child: Padding(
@@ -331,7 +360,9 @@ class _AuthFormState extends State<AuthForm> {
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
),
) )
: const Icon(Icons.arrow_forward, size: 20), : const Icon(Icons.arrow_forward, size: 20),
], ],
@@ -341,6 +372,7 @@ class _AuthFormState extends State<AuthForm> {
], ],
), ),
), ),
),
if (state.status == AuthStatus.success || if (state.status == AuthStatus.success ||
state.status == AuthStatus.error) ...[ state.status == AuthStatus.error) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
+3 -2
View File
@@ -10,7 +10,8 @@ import 'form.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthPage extends StatefulWidget { class AuthPage extends StatefulWidget {
const AuthPage({super.key}); const AuthPage({super.key, this.email});
final String? email;
@override @override
State<AuthPage> createState() => _AuthPageState(); State<AuthPage> createState() => _AuthPageState();
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
Center( Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey), child: AuthForm(key: _formKey, email: widget.email),
), ),
), ),
], ],
+206
View File
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../generated/i18n/app_localizations.dart';
/// Birthday entry with a canonical date string at the profile boundary.
class BirthField extends StatefulWidget {
const BirthField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
@override
State<BirthField> createState() => _BirthFieldState();
}
class _BirthFieldState extends State<BirthField> {
final _day = TextEditingController();
final _year = TextEditingController();
final _monthField = GlobalKey<FormFieldState<int>>();
int? _month;
late String _emitted;
@override
void initState() {
super.initState();
_readValue();
}
@override
void didUpdateWidget(BirthField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _emitted) {
_readValue();
_monthField.currentState?.didChange(_month);
}
}
void _readValue() {
_emitted = widget.value;
final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value);
_year.text = parts?.group(1) ?? '';
final month = int.tryParse(parts?.group(2) ?? '');
_month = month != null && month >= 1 && month <= 12 ? month : null;
_day.text = parts?.group(3) ?? '';
}
DateTime? get _date {
final day = int.tryParse(_day.text);
final year = int.tryParse(_year.text);
if (day == null ||
year == null ||
year < 1 ||
_year.text.length != 4 ||
_month == null) {
return null;
}
final date = DateTime(year, _month!, day);
return date.year == year && date.month == _month && date.day == day
? date
: null;
}
bool _future(DateTime date) =>
date.isAfter(DateUtils.dateOnly(DateTime.now()));
void _change() {
setState(() {});
final date = _date;
_emitted = date == null || _future(date)
? ''
: '${_year.text}-${_month.toString().padLeft(2, '0')}-'
'${_day.text.padLeft(2, '0')}';
widget.onChanged(_emitted);
}
@override
void dispose() {
_day.dispose();
_year.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final date = _date;
final complete =
_day.text.isNotEmpty && _month != null && _year.text.length == 4;
final error = !complete
? null
: date == null
? l.kycBirthInvalid
: _future(date)
? l.kycBirthFuture
: null;
final months = DateFormat.MMMM(Localizations.localeOf(context).toString());
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
final day = _number(
key: 'birth-day',
controller: _day,
label: l.kycBirthDay,
length: 2,
autofill: AutofillHints.birthdayDay,
);
final month = DropdownButtonFormField<int>(
key: _monthField,
initialValue: _month,
isExpanded: true,
decoration: InputDecoration(labelText: l.kycBirthMonth),
items: [
for (var month = 1; month <= 12; month++)
DropdownMenuItem(
value: month,
child: Text(months.format(DateTime(2000, month))),
),
],
onChanged: !widget.enabled
? null
: (value) {
_month = value;
_change();
},
);
final year = _number(
key: 'birth-year',
controller: _year,
label: l.kycBirthYear,
length: 4,
autofill: AutofillHints.birthdayYear,
);
final scale = MediaQuery.textScalerOf(context).scale(16) / 16;
if (constraints.maxWidth < 400 * scale) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
day,
const SizedBox(height: 16),
month,
const SizedBox(height: 16),
year,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: day),
const SizedBox(width: 12),
Expanded(flex: 4, child: month),
const SizedBox(width: 12),
Expanded(flex: 3, child: year),
],
);
},
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Semantics(
liveRegion: true,
child: Text(
error,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
],
);
}
Widget _number({
required String key,
required TextEditingController controller,
required String label,
required int length,
required String autofill,
}) => TextFormField(
key: ValueKey(key),
controller: controller,
enabled: widget.enabled,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
autofillHints: [autofill],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(length),
],
decoration: InputDecoration(labelText: label),
onChanged: (_) => _change(),
);
}
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:country_picker/country_picker.dart';
import '../generated/i18n/app_localizations.dart';
class CountryField extends StatelessWidget {
const CountryField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final country = CountryService().findByCode(value);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycCountry, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
Semantics(
label: l.kycCountry,
child: OutlinedButton(
onPressed: !enabled
? null
: () async {
final code = await showDialog<String>(
context: context,
builder: (_) => _CountrySearch(selected: value),
);
if (context.mounted && code != null) onChanged(code);
},
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Row(
children: [
Expanded(
child: Text(
country == null
? l.kycCountrySelect
: _name(context, country),
),
),
const SizedBox(width: 8),
const Icon(Icons.expand_more),
],
),
),
),
],
);
}
}
String _name(BuildContext context, Country country) =>
country.getTranslatedName(context) ?? country.name;
class _CountrySearch extends StatefulWidget {
const _CountrySearch({required this.selected});
final String selected;
@override
State<_CountrySearch> createState() => _CountrySearchState();
}
class _CountrySearchState extends State<_CountrySearch> {
final _query = TextEditingController();
final _countries = {
for (final country in CountryService().getAll())
country.countryCode: country,
}.values.toList();
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Focus(
autofocus: true,
onKeyEvent: (_, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => Navigator.of(context).pop()),
title: TextField(
key: const ValueKey('country-search'),
controller: _query,
autofocus: true,
decoration: InputDecoration(
labelText: l.kycCountrySearch,
prefixIcon: const Icon(Icons.search),
),
onChanged: (_) => setState(() {}),
),
toolbarHeight: 96,
actions: [
if (_query.text.isNotEmpty)
IconButton(
tooltip: l.kycCountryClearSearch,
icon: const Icon(Icons.clear),
onPressed: () => setState(_query.clear),
),
],
),
body: _list(context),
),
),
);
}
Widget _list(BuildContext context) {
final search = _query.text.trim().toLowerCase();
final countries = _countries.where((country) {
return _name(context, country).toLowerCase().contains(search) ||
country.name.toLowerCase().contains(search) ||
country.countryCode.toLowerCase() == search;
}).toList()..sort((a, b) => _name(context, a).compareTo(_name(context, b)));
if (countries.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(AppLocalizations.of(context)!.kycCountryEmpty),
),
);
}
return ListView.builder(
itemCount: countries.length,
itemBuilder: (context, index) {
final country = countries[index];
final isSelected = country.countryCode == widget.selected;
return ListTile(
title: Text(_name(context, country)),
selected: isSelected,
trailing: isSelected ? const Icon(Icons.check) : null,
onTap: () => Navigator.of(context).pop(country.countryCode),
);
},
);
}
}
+18 -4
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
@@ -15,8 +17,12 @@ class PhotoPreview extends StatefulWidget {
class _PhotoPreviewState extends State<PhotoPreview> { class _PhotoPreviewState extends State<PhotoPreview> {
late ImageProvider _image = _provider(); late ImageProvider _image = _provider();
ImageProvider _provider() => ImageProvider _provider() => ResizeImage.resizeIfNeeded(
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes)); 1000,
null,
// Web decoders require a native typed array, not the model's read-only view.
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
);
@override @override
void didUpdateWidget(covariant PhotoPreview oldWidget) { void didUpdateWidget(covariant PhotoPreview oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -38,7 +44,15 @@ class _PhotoPreviewState extends State<PhotoPreview> {
height: 220, height: 220,
fit: BoxFit.contain, fit: BoxFit.contain,
semanticLabel: widget.label, semanticLabel: widget.label,
errorBuilder: (_, _, _) => errorBuilder: (_, _, _) => ConstrainedBox(
Text(AppLocalizations.of(context)!.kycErrorUnreadable), constraints: const BoxConstraints(minHeight: 220),
child: Center(
child: Semantics(
container: true,
liveRegion: true,
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
),
),
),
); );
} }
+9 -18
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'birth.dart';
import 'country.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class ProfileForm extends StatelessWidget { class ProfileForm extends StatelessWidget {
@@ -35,30 +38,18 @@ class ProfileForm extends StatelessWidget {
onChanged: (value) => update(name: value), onChanged: (value) => update(name: value),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( BirthField(
key: const ValueKey('kyc-birth'), key: const ValueKey('kyc-birth'),
initialValue: p.profile.dateOfBirth, value: p.profile.dateOfBirth,
enabled: !p.busy, enabled: !p.busy,
keyboardType: TextInputType.datetime, onChanged: (value) => update(birth: value),
autofillHints: const [AutofillHints.birthday],
decoration: InputDecoration(
labelText: l.kycBirth,
helperText: l.kycBirthHelp,
),
onChanged: (value) => update(birth: value.trim()),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( CountryField(
key: const ValueKey('kyc-country'), key: const ValueKey('kyc-country'),
initialValue: p.profile.residenceCountry, value: p.profile.residenceCountry,
enabled: !p.busy, enabled: !p.busy,
maxLength: 2, onChanged: (value) => update(country: value),
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: l.kycCountry,
helperText: l.kycCountryHelp,
),
onChanged: (value) => update(country: value.trim().toUpperCase()),
), ),
if (!p.profile.isComplete) Text(l.kycProfileRequired), if (!p.profile.isComplete) Text(l.kycProfileRequired),
], ],
+16 -4
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Send a new verification email", "verificationRequest": "Send a new verification email",
"verificationRetry": "Retry verification", "verificationRetry": "Retry verification",
"verificationBack": "Back to login", "verificationBack": "Back to login",
"verificationContinue": "Continue to login",
"verificationHint": "No email? Check your spam folder or request another below.", "verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemoLink": "Continue to confirmation", "verificationDemoLink": "Continue to confirmation",
"verificationStep": "CONFIRM YOUR EMAIL", "verificationStep": "CONFIRM YOUR EMAIL",
@@ -127,6 +128,7 @@
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.", "kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.", "kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.", "kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
"kycErrorPreview": "The photo preview could not be displayed. Try choosing the photo again.",
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.", "kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.", "kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.", "kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
@@ -159,7 +161,10 @@
"wRecent": "Recent activity", "wRecent": "Recent activity",
"wSeeAll": "See all", "wSeeAll": "See all",
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.", "wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
"wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.", "wEmptyAccounts": "We couldn’t finish setting up your currency accounts. Try again to continue.",
"wAccountsLoading": "Getting your RUB, USDT and USDC accounts ready…",
"wAccountsFailed": "We couldn’t load your currency accounts. Try again to continue.",
"wAccountsRetry": "Try again",
"wRefresh": "Refresh", "wRefresh": "Refresh",
"wSource": "From account", "wSource": "From account",
"wDestination": "To account", "wDestination": "To account",
@@ -268,10 +273,17 @@
"kycProfileHelp": "Enter your details exactly as shown on your identity document.", "kycProfileHelp": "Enter your details exactly as shown on your identity document.",
"kycFullName": "Full legal name", "kycFullName": "Full legal name",
"kycBirth": "Date of birth", "kycBirth": "Date of birth",
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23", "kycBirthDay": "Day",
"kycBirthMonth": "Month",
"kycBirthYear": "Year",
"kycBirthInvalid": "Enter a valid date of birth.",
"kycBirthFuture": "Date of birth cannot be in the future.",
"kycCountry": "Country of residence", "kycCountry": "Country of residence",
"kycCountryHelp": "Two-letter country code, for example PL or DE", "kycCountrySelect": "Select a country",
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.", "kycCountrySearch": "Search countries",
"kycCountryEmpty": "No countries found. Try another name or country code.",
"kycCountryClearSearch": "Clear search",
"kycProfileRequired": "Enter your full name, a valid date of birth and select your country of residence to continue.",
"kycContinue": "Continue to my account", "kycContinue": "Continue to my account",
"kycOperationMinimum": "Per operation minimum", "kycOperationMinimum": "Per operation minimum",
"kycOperationMaximum": "Per operation maximum", "kycOperationMaximum": "Per operation maximum",
+16 -4
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Отправить новое письмо", "verificationRequest": "Отправить новое письмо",
"verificationRetry": "Повторить подтверждение", "verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу", "verificationBack": "Вернуться ко входу",
"verificationContinue": "Перейти ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemoLink": "Перейти к подтверждению", "verificationDemoLink": "Перейти к подтверждению",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
@@ -127,6 +128,7 @@
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.", "kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.", "kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.", "kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.", "kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.", "kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
@@ -159,7 +161,10 @@
"wRecent": "Последние операции", "wRecent": "Последние операции",
"wSeeAll": "Смотреть все", "wSeeAll": "Смотреть все",
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.", "wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
"wAccountsRetry": "Повторить попытку",
"wRefresh": "Обновить", "wRefresh": "Обновить",
"wSource": "Со счёта", "wSource": "Со счёта",
"wDestination": "На счёт", "wDestination": "На счёт",
@@ -268,10 +273,17 @@
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.", "kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
"kycFullName": "Полное имя по документу", "kycFullName": "Полное имя по документу",
"kycBirth": "Дата рождения", "kycBirth": "Дата рождения",
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23", "kycBirthDay": "День",
"kycBirthMonth": "Месяц",
"kycBirthYear": "Год",
"kycBirthInvalid": "Укажите корректную дату рождения.",
"kycBirthFuture": "Дата рождения не может быть в будущем.",
"kycCountry": "Страна проживания", "kycCountry": "Страна проживания",
"kycCountryHelp": "Код страны из двух букв, например PL или DE", "kycCountrySelect": "Выберите страну",
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.", "kycCountrySearch": "Поиск страны",
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
"kycCountryClearSearch": "Очистить поиск",
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
"kycContinue": "Перейти в личный кабинет", "kycContinue": "Перейти в личный кабинет",
"kycOperationMinimum": "Минимум на операцию", "kycOperationMinimum": "Минимум на операцию",
"kycOperationMaximum": "Максимум на операцию", "kycOperationMaximum": "Максимум на операцию",
+9 -1
View File
@@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:country_picker/country_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart';
@@ -18,6 +22,7 @@ import 'routing/router.dart';
import 'generated/i18n/app_localizations.dart'; import 'generated/i18n/app_localizations.dart';
Future<void> main() async { Future<void> main() async {
usePathUrlStrategy();
final binding = WidgetsFlutterBinding.ensureInitialized(); final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales); final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL'); const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
@@ -192,7 +197,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: SxchngeTheme.light(), theme: SxchngeTheme.light(),
locale: locale.locale, locale: locale.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
CountryLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router, routerConfig: _router,
), ),
+8 -1
View File
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart'; import '../auth/page.dart';
@@ -135,13 +137,18 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute( GoRoute(
path: '/', path: '/',
name: AppRoutes.login, name: AppRoutes.login,
builder: (context, state) => const AuthPage(), builder: (context, state) => AuthPage(
email: state.extra is String ? state.extra as String : null,
),
), ),
GoRoute( GoRoute(
path: '/verification-sent', path: '/verification-sent',
name: AppRoutes.verificationSent, name: AppRoutes.verificationSent,
builder: (context, state) => VerificationPage( builder: (context, state) => VerificationPage(
email: state.extra is String ? state.extra as String : null, email: state.extra is String ? state.extra as String : null,
delivery: state.extra is VerificationDelivery
? state.extra as VerificationDelivery
: null,
), ),
), ),
GoRoute( GoRoute(
+31 -9
View File
@@ -15,9 +15,10 @@ import '../routing/routes.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class VerificationPage extends StatefulWidget { class VerificationPage extends StatefulWidget {
const VerificationPage({super.key, this.email, this.token}); const VerificationPage({super.key, this.email, this.token, this.delivery});
final String? email; final String? email;
final String? token; final String? token;
final VerificationDelivery? delivery;
@override @override
State<VerificationPage> createState() => _VerificationPageState(); State<VerificationPage> createState() => _VerificationPageState();
@@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget {
class _VerificationPageState extends State<VerificationPage> { class _VerificationPageState extends State<VerificationPage> {
final _form = GlobalKey<FormState>(); final _form = GlobalKey<FormState>();
late final _email = TextEditingController(text: widget.email); late final _email = TextEditingController(
text: widget.delivery?.email ?? widget.email,
);
late final _provider = VerificationProvider( late final _provider = VerificationProvider(
context.read<VerificationService>(), context.read<VerificationService>(),
delivery: widget.delivery,
); );
final _emailFocus = FocusNode(); final _emailFocus = FocusNode();
String? _recipient; String? _recipient;
@@ -44,7 +48,7 @@ class _VerificationPageState extends State<VerificationPage> {
if (!mounted) return; if (!mounted) return;
if (widget.token != null) { if (widget.token != null) {
_provider.confirm(widget.token!); _provider.confirm(widget.token!);
} else if (_email.text.isNotEmpty) { } else if (widget.delivery == null && _email.text.isNotEmpty) {
_send(); _send();
} }
}); });
@@ -58,14 +62,26 @@ class _VerificationPageState extends State<VerificationPage> {
@override @override
void didUpdateWidget(covariant VerificationPage oldWidget) { void didUpdateWidget(covariant VerificationPage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.token == widget.token) return; if (oldWidget.token == widget.token &&
oldWidget.email == widget.email &&
identical(oldWidget.delivery, widget.delivery)) {
return;
}
if (oldWidget.token != widget.token) {
context.read<JourneyRecorder>().abandon(JourneyExit.navigation); context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
_provider.reset(); }
_provider.reset(delivery: widget.delivery);
_lastWasSend = false; _lastWasSend = false;
_validated = false; _validated = false;
_email.clear(); _recipient = null;
_email.text = widget.delivery?.email ?? widget.email ?? '';
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.token != null) _provider.confirm(widget.token!); if (!mounted) return;
if (widget.token != null) {
_provider.confirm(widget.token!);
} else if (widget.delivery == null && _email.text.isNotEmpty) {
_send();
}
}); });
} }
@@ -318,16 +334,22 @@ class _VerificationPageState extends State<VerificationPage> {
if (verified) ...[ if (verified) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: () => context.go('/accounts'), key: const ValueKey('verification-continue'),
child: Text(l.wEnter), onPressed: () => context.goNamed(
AppRoutes.login,
extra: state.result?.verifiedEmail,
),
child: Text(l.verificationContinue),
), ),
], ],
if (!verified) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton( TextButton(
onPressed: () => context.goNamed(AppRoutes.login), onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack), child: Text(l.verificationBack),
), ),
], ],
],
), ),
), ),
), ),
+22 -3
View File
@@ -65,15 +65,34 @@ class AccountsPage extends StatelessWidget {
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
if (p.loading) const LinearProgressIndicator(), if (p.loading) const LinearProgressIndicator(),
if (p.failed) if ((p.loading || (!p.failed && p.data == null)) &&
accounts.isEmpty)
Semantics(liveRegion: true, child: Text(l.wAccountsLoading)),
if (p.failed && accounts.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(l.wUnavailable, style: TextStyle(color: c.error)), child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
), ),
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts), if (!p.loading &&
accounts.isEmpty &&
(p.failed || p.data != null)) ...[
Semantics(
liveRegion: true,
child: Text(p.failed ? l.wAccountsFailed : l.wEmptyAccounts),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: p.refresh,
child: Text(l.wAccountsRetry),
),
),
],
if (accountId != null && if (accountId != null &&
p.account(accountId) == null && p.account(accountId) == null &&
!p.loading) !p.loading &&
!p.failed &&
p.data != null)
Text(l.wNotFound), Text(l.wNotFound),
if (selected != null) ...[ if (selected != null) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
+15 -3
View File
@@ -256,10 +256,22 @@ class TransferPageState extends State<TransferPage> {
), ),
] else ...[ ] else ...[
if (accounts.isEmpty) ...[ if (accounts.isEmpty) ...[
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts), Semantics(
liveRegion: true,
child: Text(
wallet.loading ||
(!wallet.failed && wallet.data == null)
? l.wAccountsLoading
: wallet.failed
? l.wAccountsFailed
: l.wEmptyAccounts,
),
),
if (!wallet.loading &&
(wallet.failed || wallet.data != null))
TextButton( TextButton(
onPressed: wallet.loading ? null : wallet.refresh, onPressed: wallet.refresh,
child: Text(l.wRefresh), child: Text(l.wAccountsRetry),
), ),
] else ...[ ] else ...[
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
+19 -3
View File
@@ -49,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
country_picker:
dependency: "direct main"
description:
name: country_picker
sha256: "8ef721183bd7e0a6017c413693cdc68051daceef5790f5420c3b4378b5c69115"
url: "https://pub.dev"
source: hosted
version: "2.0.28"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@@ -185,7 +193,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
@@ -198,7 +206,7 @@ packages:
source: hosted source: hosted
version: "18.0.1" version: "18.0.1"
http: http:
dependency: transitive dependency: "direct dev"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -214,7 +222,7 @@ packages:
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image: image:
dependency: transitive dependency: "direct dev"
description: description:
name: image name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
@@ -525,6 +533,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+5
View File
@@ -14,6 +14,8 @@ dependencies:
path: ../packages/sxchnge_analytics path: ../packages/sxchnge_analytics
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
sxchnge_auth: sxchnge_auth:
path: ../packages/sxchnge_auth path: ../packages/sxchnge_auth
sxchnge_theme: sxchnge_theme:
@@ -25,10 +27,13 @@ dependencies:
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
go_router: ^18.0.1 go_router: ^18.0.1
country_picker: ^2.0.28
dev_dependencies: dev_dependencies:
image: ^4.9.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
http: ^1.6.0
flutter: flutter:
generate: true generate: true
uses-material-design: true uses-material-design: true
+104
View File
@@ -0,0 +1,104 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class SetupWallet extends DummyWalletService {
SetupWallet(String locale)
: super(
DummyIdentityService(delay: Duration.zero),
delay: Duration.zero,
initialLocale: locale,
);
Completer<WalletSnapshot> pending = Completer<WalletSnapshot>();
int calls = 0;
@override
Future<WalletSnapshot> fetch() {
calls++;
return pending.future;
}
}
WalletSnapshot readyAccounts() => WalletSnapshot(
accounts: [
for (final currency in ['RUB', 'USDT', 'USDC'])
CurrencyAccount(
id: currency.toLowerCase(),
currency: currency,
precision: currency == 'RUB' ? 2 : 6,
available: 0,
),
],
operations: [],
);
void main() {
for (final locale in ['en', 'ru']) {
for (final path in ['/accounts', '/top-up', '/exchange', '/payout']) {
testWidgets(
'$path recovers account setup in $locale at narrow width and large text',
(t) async {
t.view.physicalSize = const Size(320, 1100);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
t.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final wallet = SetupWallet(locale);
await t.pumpWidget(
SxchngeApp(
walletService: wallet,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
),
);
await t.pump();
await t.pump();
final loading = locale == 'en'
? 'Getting your RUB, USDT and USDC accounts ready…'
: 'Подготавливаем ваши счета в RUB, USDT и USDC…';
final failed = locale == 'en'
? 'We couldn’t load your currency accounts. Try again to continue.'
: 'Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.';
final retry = locale == 'en' ? 'Try again' : 'Повторить попытку';
expect(find.text(loading), findsOneWidget);
expect(find.textContaining('Contact support'), findsNothing);
expect(t.takeException(), isNull);
wallet.pending.completeError(
const WalletException(WalletError.unavailable),
);
await t.pumpAndSettle();
expect(find.text(failed), findsOneWidget);
expect(find.text(loading), findsNothing);
expect(find.text(retry), findsOneWidget);
expect(t.takeException(), isNull);
wallet.pending = Completer<WalletSnapshot>();
await t.ensureVisible(find.text(retry));
await t.tap(find.text(retry));
await t.pump();
expect(wallet.calls, 2);
expect(find.text(loading), findsOneWidget);
expect(find.text(failed), findsNothing);
wallet.pending.complete(readyAccounts());
await t.pumpAndSettle();
expect(find.text(loading), findsNothing);
expect(find.text(failed), findsNothing);
expect(find.text('RUB'), findsWidgets);
expect(t.takeException(), isNull);
await t.pumpWidget(const SizedBox.shrink());
},
);
}
}
}
+38
View File
@@ -92,6 +92,14 @@ void main() {
findsOneWidget, findsOneWidget,
); );
expect(find.byKey(const ValueKey('verification-sent')), findsNothing); expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
tester tester
.widget<TextFormField>(find.byKey(const ValueKey('email'))) .widget<TextFormField>(find.byKey(const ValueKey('email')))
@@ -153,6 +161,14 @@ void main() {
'/verification-sent', '/verification-sent',
); );
expect(backend.sends, 1); expect(backend.sends, 1);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
find.textContaining('a verification email is on its way'), find.textContaining('a verification email is on its way'),
findsOneWidget, findsOneWidget,
@@ -198,6 +214,14 @@ void main() {
find.text('Сервис недоступен. Попробуйте ещё раз.'), find.text('Сервис недоступен. Попробуйте ещё раз.'),
findsOneWidget, findsOneWidget,
); );
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.tap(button); await tester.tap(button);
await tester.pump(); await tester.pump();
expect(service.calls, hasLength(2)); expect(service.calls, hasLength(2));
@@ -207,6 +231,12 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(find.byKey(const ValueKey('auth-error')), findsNothing); expect(find.byKey(const ValueKey('auth-error')), findsNothing);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, },
); );
@@ -237,6 +267,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget); expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
+28 -2
View File
@@ -55,6 +55,12 @@ void main() {
await submit(tester); await submit(tester);
expect(find.text('Enter your email address.'), findsOneWidget); expect(find.text('Enter your email address.'), findsOneWidget);
expect(find.text('Enter your password.'), findsOneWidget); expect(find.text('Enter your password.'), findsOneWidget);
expect(
tester.testTextInput.log.where(
(call) => call.method == 'TextInput.finishAutofillContext',
),
isEmpty,
);
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('email')), find.byKey(const ValueKey('email')),
'test@example.com', 'test@example.com',
@@ -79,6 +85,12 @@ void main() {
await tester.testTextInput.receiveAction(TextInputAction.done); await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, },
); );
@@ -104,12 +116,26 @@ void main() {
); );
await submit(tester); await submit(tester);
expect(find.text('Passwords do not match.'), findsOneWidget); expect(find.text('Passwords do not match.'), findsOneWidget);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('confirmation')), find.byKey(const ValueKey('confirmation')),
'example-password', 'example-password',
); );
await submit(tester); await submit(tester);
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
await tester.ensureVisible( await tester.ensureVisible(
find.byKey(const ValueKey('demo-confirmation')), find.byKey(const ValueKey('demo-confirmation')),
); );
@@ -119,8 +145,8 @@ void main() {
find.byKey(const ValueKey('verification-verified')), find.byKey(const ValueKey('verification-verified')),
findsOneWidget, findsOneWidget,
); );
await tester.ensureVisible(find.text('Back to login')); await tester.ensureVisible(find.text('Continue to login'));
await tester.tap(find.text('Back to login')); await tester.tap(find.text('Continue to login'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsNothing); expect(find.byKey(const ValueKey('confirmation')), findsNothing);
expect(find.text('example-password'), findsNothing); expect(find.text('example-password'), findsNothing);
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
Finder field(String key) => find.byKey(ValueKey(key));
EditableTextState editor(WidgetTester tester, String key) =>
tester.state<EditableTextState>(
find.descendant(of: field(key), matching: find.byType(EditableText)),
);
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'platform autofill fills the signup credential group ($language)',
(tester) async {
tester.view.physicalSize = const Size(1440, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
);
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final configuration = tester.testTextInput.setClientArgs!;
expect(
configuration['inputType']['name'],
'TextInputType.emailAddress',
);
final fields = (configuration['fields'] as List).cast<Map>();
expect(fields, hasLength(4));
expect(
fields.map((field) => field['autofill']['hints'].first),
containsAll(['name', 'username', 'newPassword', 'newPassword']),
);
const values = {
'name': 'Alex Example',
'email': 'alex@example.com',
'password': 'generated-example-password',
'confirmation': 'generated-example-password',
};
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall('TextInputClient.updateEditingStateWithTag', [
0,
{
for (final entry in values.entries)
editor(tester, entry.key).autofillId: TextEditingValue(
text: entry.value,
).toJSON(),
},
]),
),
(_) {},
);
await tester.pumpAndSettle();
for (final entry in values.entries) {
expect(
editor(tester, entry.key).currentTextEditingValue.text,
entry.value,
);
}
// A visibility change must retain both the supplied value and its role.
await tester.tap(
find.byTooltip(
language == 'ru' ? 'Показать пароль' : 'Show password',
),
);
await tester.pump();
expect(editor(tester, 'password').widget.obscureText, isFalse);
expect(editor(tester, 'password').widget.autofillHints, [
'newPassword',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
values['password'],
);
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final loginFields =
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
expect(loginFields, hasLength(2));
expect(loginFields.map((field) => field['autofill']['hints'].first), [
'username',
'password',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
isEmpty,
);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
},
);
}
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
await tester.pumpWidget(const SxchngeApp());
await tester.pumpAndSettle();
await tester.enterText(field('email'), 'alex@example.com');
await tester.enterText(field('password'), 'unfinished-password');
tester.testTextInput.log.clear();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
});
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final day = find.byKey(const ValueKey('birth-day'));
final year = find.byKey(const ValueKey('birth-year'));
final month = find.byType(DropdownButtonFormField<int>);
Future<void> mount(
WidgetTester t,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await t.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, date, _) => BirthField(
value: date,
enabled: enabled,
onChanged: (date) => value.value = date,
),
),
),
),
),
);
await t.pumpAndSettle();
}
Future<void> select(WidgetTester t, String name) async {
await t.ensureVisible(month);
await t.tap(month);
await t.pumpAndSettle();
await t.tap(find.text(name).last);
await t.pumpAndSettle();
}
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('Date of birth'), findsOneWidget);
await t.enterText(day, '23');
await select(t, 'April');
expect(value.value, '');
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-23');
await t.enterText(day, '9');
await t.pumpAndSettle();
expect(value.value, '1990-04-09');
await t.enterText(year, '19');
await t.pumpAndSettle();
expect(value.value, '');
// A provider rebuild must retain the other components of an incomplete date.
expect(t.widget<TextFormField>(day).controller!.text, '9');
expect(find.text('April'), findsOneWidget);
await t.enterText(year, '1985');
await t.pumpAndSettle();
expect(value.value, '1985-04-09');
});
testWidgets('leap dates, impossible days and future dates recover inline', (
t,
) async {
final value = ValueNotifier('2000-02-29');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('February'), findsOneWidget);
await t.enterText(year, '2001');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
expect(t.widget<TextFormField>(day).controller!.text, '29');
await t.enterText(year, '2004');
await t.pumpAndSettle();
expect(value.value, '2004-02-29');
expect(find.text('Enter a valid date of birth.'), findsNothing);
await t.enterText(day, '31');
await select(t, 'April');
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '30');
await t.enterText(year, '${DateTime.now().year + 1}');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-30');
await t.enterText(day, '0');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '1');
await t.enterText(year, '0000');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
});
testWidgets(
'prefill, external replacement, locale change and clear synchronize',
(t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
expect(t.widget<TextFormField>(year).controller!.text, '1990');
value.value = '1985-12-09';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '09');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(find.text('December'), findsOneWidget);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('Дата рождения'), findsOneWidget);
expect(find.text('декабрь'), findsOneWidget);
expect(value.value, '1985-12-09');
await select(t, 'апрель');
expect(value.value, '1985-04-09');
value.value = '';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '');
expect(t.widget<TextFormField>(year).controller!.text, '');
expect(find.text('апрель'), findsNothing);
},
);
testWidgets('digits and length limits keep each component usable', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.enterText(day, '');
await t.enterText(day, 'a123');
await t.enterText(year, '');
await t.enterText(year, 'b19856');
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '12');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(value.value, '1985-04-12');
});
testWidgets('busy state disables every birthday control', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value, enabled: false);
expect(t.widget<TextFormField>(day).enabled, isFalse);
expect(t.widget<TextFormField>(year).enabled, isFalse);
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
expect(value.value, '1990-04-23');
});
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
t,
) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: day, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.escape);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: year, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
});
testWidgets(
'Russian controls and errors fit 320px with large text and labeled targets',
(t) async {
t.view.physicalSize = const Size(320, 900);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
final semantics = t.ensureSemantics();
final value = ValueNotifier('1990-09-23');
addTearDown(value.dispose);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('сентябрь'), findsOneWidget);
expect(t, meetsGuideline(labeledTapTargetGuideline));
expect(t, meetsGuideline(androidTapTargetGuideline));
await t.enterText(day, '31');
await t.pumpAndSettle();
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
await select(t, 'март');
expect(value.value, '1990-03-31');
expect(t.takeException(), isNull);
semantics.dispose();
},
);
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:country_picker/country_picker.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final search = find.byKey(const ValueKey('country-search'));
final button = find.byType(OutlinedButton);
Future<void> mount(
WidgetTester tester,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
CountryLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, code, _) => CountryField(
value: code,
enabled: enabled,
onChanged: (code) => value.value = code,
),
),
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> open(WidgetTester tester) async {
await tester.tap(button);
await tester.pumpAndSettle();
}
testWidgets(
'name search stores code, reselection and cancel preserve value',
(tester) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(tester, value);
expect(find.text('Select a country'), findsOneWidget);
await open(tester);
await tester.enterText(search, 'polAND');
await tester.pumpAndSettle();
await tester.tap(find.text('Poland'));
await tester.pumpAndSettle();
expect(value.value, 'PL');
expect(find.text('Poland'), findsOneWidget);
expect(find.text('0/2'), findsNothing);
await open(tester);
await tester.enterText(search, ' de ');
await tester.pumpAndSettle();
await tester.tap(find.text('Germany'));
await tester.pumpAndSettle();
expect(value.value, 'DE');
expect(find.text('Germany'), findsOneWidget);
await open(tester);
await tester.enterText(search, 'poland');
await tester.pumpAndSettle();
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(value.value, 'DE');
expect(find.text('Germany'), findsOneWidget);
},
);
testWidgets('no-results state recovers through clear and Escape cancels', (
tester,
) async {
final value = ValueNotifier('PL');
addTearDown(value.dispose);
await mount(tester, value);
await open(tester);
await tester.enterText(search, 'no-such-country');
await tester.pumpAndSettle();
expect(find.textContaining('No countries found'), findsOneWidget);
expect(find.byType(ListTile), findsNothing);
expect(value.value, 'PL');
await tester.tap(find.byTooltip('Clear search'));
await tester.pumpAndSettle();
expect(find.textContaining('No countries found'), findsNothing);
expect(find.byType(ListTile), findsWidgets);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(search, findsNothing);
expect(value.value, 'PL');
});
testWidgets('saved code follows locale and Russian names are searchable', (
tester,
) async {
final value = ValueNotifier('PL');
addTearDown(value.dispose);
await mount(tester, value);
expect(find.text('Poland'), findsOneWidget);
await mount(tester, value, locale: const Locale('ru'));
expect(find.text('Польша'), findsOneWidget);
expect(value.value, 'PL');
await open(tester);
await tester.enterText(search, 'Германия');
await tester.pumpAndSettle();
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
expect(value.value, 'DE');
expect(find.text('Германия'), findsOneWidget);
});
testWidgets('busy field cannot open or change selection', (tester) async {
final value = ValueNotifier('PL');
addTearDown(value.dispose);
await mount(tester, value, enabled: false);
expect(tester.widget<OutlinedButton>(button).onPressed, isNull);
await tester.tap(button);
await tester.pumpAndSettle();
expect(search, findsNothing);
expect(value.value, 'PL');
});
testWidgets(
'keyboard and labeled touch targets work at 320px with large text',
(tester) async {
tester.view.physicalSize = const Size(320, 900);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final semantics = tester.ensureSemantics();
final value = ValueNotifier('GS');
addTearDown(value.dispose);
await mount(tester, value);
expect(tester, meetsGuideline(labeledTapTargetGuideline));
expect(tester, meetsGuideline(androidTapTargetGuideline));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(search, findsOneWidget);
await tester.enterText(search, 'GS');
await tester.pumpAndSettle();
expect(tester.widget<ListTile>(find.byType(ListTile)).selected, isTrue);
expect(tester, meetsGuideline(labeledTapTargetGuideline));
expect(tester, meetsGuideline(androidTapTargetGuideline));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(search, findsNothing);
expect(value.value, 'GS');
expect(tester.takeException(), isNull);
semantics.dispose();
},
);
}
+63 -3
View File
@@ -11,6 +11,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart'; import 'package:sxchnge_app/providers/locale.dart';
@@ -65,12 +67,35 @@ void main() {
return service; return service;
} }
Future<void> submit(WidgetTester t) async { Future<void> profile(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start'))); await tap(t, find.byKey(const ValueKey('kyc-start')));
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person'); await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23'); await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL'); await tap(t, find.byType(DropdownButtonFormField<int>));
// The month label follows the selected app locale.
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
await tap(
t,
find.descendant(
of: find.byKey(const ValueKey('kyc-country')),
matching: find.byType(OutlinedButton),
),
);
await t.enterText(find.byKey(const ValueKey('country-search')), 'PL');
await t.pumpAndSettle();
await tap(t, find.byType(ListTile));
expect(
t.widget<CountryField>(find.byKey(const ValueKey('kyc-country'))).value,
'PL',
);
await t.pumpAndSettle();
}
Future<void> submit(WidgetTester t) async {
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport'))); await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next'))); await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byKey(const ValueKey('pick-selfie'))); await tap(t, find.byKey(const ValueKey('pick-selfie')));
@@ -79,6 +104,41 @@ void main() {
await tap(t, find.byKey(const ValueKey('kyc-submit'))); await tap(t, find.byKey(const ValueKey('kyc-submit')));
} }
testWidgets(
'invalid birthday blocks advancing and valid birthday survives Back',
(t) async {
await open(t);
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport')));
final next = find.byKey(const ValueKey('kyc-next'));
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNull);
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
await tap(t, next);
await tap(t, find.text('Back'));
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
.controller!
.text,
'23',
);
expect(find.text('April'), findsOneWidget);
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
.controller!
.text,
'1990',
);
},
);
testWidgets('two photos, review, pending and approval with service limits', ( testWidgets('two photos, review, pending and approval with service limits', (
t, t,
) async { ) async {
+229
View File
@@ -0,0 +1,229 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as images;
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/photo.dart';
import 'package:sxchnge_app/kyc/preview.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SingleChildScrollView(child: child)),
);
// Start decoding outside FakeAsync as well as waiting outside it, including
// browser codec callbacks that otherwise remain pending in the test zone.
Future<void> show(WidgetTester tester, Widget widget) async {
await tester.runAsync(() => tester.pumpWidget(widget));
}
// pumpAndSettle alone does not wait for image codecs. Listen to the actual
// preview streams so a hidden errorBuilder cannot make this regression pass.
Future<void> decoded(WidgetTester tester, {bool succeeds = true}) async {
final providers = tester
.widgetList<Image>(find.byType(Image))
.map((image) => image.image)
.toList();
expect(providers, isNotEmpty);
for (final provider in providers) {
Object? renderError;
StackTrace? renderStack;
await tester.runAsync(() async {
final stream = provider.resolve(ImageConfiguration.empty);
final ready = Completer<void>();
final listener = ImageStreamListener(
(info, _) {
info.dispose();
if (!ready.isCompleted) ready.complete();
},
onError: (Object error, StackTrace? stack) {
renderError = error;
renderStack = stack;
if (!ready.isCompleted) ready.complete();
},
);
stream.addListener(listener);
try {
await ready.future.timeout(const Duration(seconds: 15));
} finally {
stream.removeListener(listener);
}
});
expect(
renderError,
succeeds ? isNull : isNotNull,
reason: succeeds ? '$renderError\n$renderStack' : null,
);
}
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
void main() {
late Uint8List png;
late Uint8List jpeg;
setUpAll(() {
// Synthetic, non-sensitive portrait and landscape photos in both formats.
png = images.encodePng(images.Image(width: 1200, height: 1600));
jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200));
});
for (final format in ['PNG', 'JPEG']) {
testWidgets('accepted $format renders from an immutable photo model', (
tester,
) async {
final source = Uint8List.fromList(format == 'PNG' ? png : jpeg);
final photo = (await tester.runAsync(
() => FilePhotoService.validate(source),
))!;
final originalFirstByte = photo.bytes.first;
source[0] = 0;
expect(photo.bytes.first, originalFirstByte);
expect(() => photo.bytes[0] = 0, throwsUnsupportedError);
await show(
tester,
host(PhotoPreview(photo: photo, label: 'ID document photo')),
);
await decoded(tester);
final raw = tester.widget<RawImage>(find.byType(RawImage));
expect(raw.image, isNotNull);
expect(raw.image!.width, lessThanOrEqualTo(1000));
expect(
tester.widget<Image>(find.byType(Image)).semanticLabel,
'ID document photo',
);
expect(find.textContaining('damaged'), findsNothing);
expect(find.textContaining('could not be displayed'), findsNothing);
});
}
testWidgets('replacement and removal evict sensitive preview cache entries', (
tester,
) async {
final first = Photo(png);
await show(tester, host(PhotoPreview(photo: first, label: 'ID')));
await decoded(tester);
final initialProvider = tester.widget<Image>(find.byType(Image)).image;
final initialKey = await initialProvider.obtainKey(
ImageConfiguration.empty,
);
final cache = PaintingBinding.instance.imageCache;
expect(cache.statusForKey(initialKey).keepAlive, isTrue);
await show(tester, host(PhotoPreview(photo: first, label: 'Document')));
await decoded(tester);
expect(
identical(
tester.widget<Image>(find.byType(Image)).image,
initialProvider,
),
isTrue,
);
await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID')));
await decoded(tester);
expect(cache.statusForKey(initialKey).untracked, isTrue);
final replacement = tester.widget<Image>(find.byType(Image)).image;
final replacementKey = await replacement.obtainKey(
ImageConfiguration.empty,
);
expect(cache.statusForKey(replacementKey).keepAlive, isTrue);
expect(tester.widget<RawImage>(find.byType(RawImage)).image!.width, 1000);
await show(tester, host(const SizedBox()));
await tester.pumpAndSettle();
expect(cache.statusForKey(replacementKey).untracked, isTrue);
});
testWidgets('ID and selfie both render on review and after returning', (
tester,
) async {
final passport = Photo(png);
final selfie = Photo(jpeg);
for (var visit = 0; visit < 2; visit++) {
await show(
tester,
host(
Column(
children: [
PhotoPanel(
kind: PhotoKind.passport,
photo: passport,
review: true,
),
PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true),
],
),
),
);
await decoded(tester);
final previews = tester.widgetList<RawImage>(find.byType(RawImage));
expect(previews.length, 2);
expect(previews.every((image) => image.image != null), isTrue);
expect(find.text('Choose a different photo'), findsNothing);
await show(tester, host(const SizedBox()));
await tester.pumpAndSettle();
}
});
for (final locale in ['en', 'ru']) {
testWidgets(
'$locale preview failure explains recovery and can be replaced',
(tester) async {
tester.view.physicalSize = const Size(320, 900);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
var replacements = 0;
var removals = 0;
await show(
tester,
host(
PhotoPanel(
kind: PhotoKind.passport,
photo: Photo(Uint8List.fromList([1, 2, 3])),
onPick: () => replacements++,
onRemove: () => removals++,
),
locale: Locale(locale),
),
);
await decoded(tester, succeeds: false);
final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!;
expect(find.text(l.kycErrorPreview), findsOneWidget);
expect(find.text(l.kycErrorUnreadable), findsNothing);
expect(
tester.getSemantics(find.text(l.kycErrorPreview)),
matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview),
);
final replace = find.byKey(const ValueKey('pick-passport'));
await tester.ensureVisible(replace);
await tester.tap(replace);
final remove = find.text(l.kycRemove);
await tester.ensureVisible(remove);
await tester.tap(remove);
expect(replacements, 1);
expect(removals, 1);
await show(
tester,
host(
PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)),
locale: Locale(locale),
),
);
await decoded(tester);
expect(find.text(l.kycErrorPreview), findsNothing);
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
},
);
}
}
+99
View File
@@ -42,6 +42,105 @@ Future<GoRouter> openRoute(
} }
void main() { void main() {
testWidgets('queued signup receipt is transient and updates its recipient', (
tester,
) async {
final router = await openRoute(tester, '/');
await tester.pumpAndSettle();
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'first@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('first@example.com'), findsOneWidget);
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'second@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.text('first@example.com'), findsNothing);
expect(find.text('second@example.com'), findsOneWidget);
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.state.toString(), isNot(contains('second@example.com')));
expect(restored.uri.toString(), '/verification-sent');
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
router.goNamed(AppRoutes.verificationSent);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
expect(find.text('second@example.com'), findsNothing);
});
for (final language in ['en', 'ru']) {
testWidgets(
'verified link keeps success visible and prefills login ($language)',
(tester) async {
tester.platformDispatcher.defaultRouteNameTestValue =
'/verify-email?token=demo-valid';
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
verificationService: const DummyVerificationService(
delay: Duration.zero,
verifiedEmail: 'private-alias@example.com',
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-verified')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('email')), findsNothing);
final router = GoRouter.of(
tester.element(find.byType(VerificationPage)),
);
expect(router.routeInformationProvider.value.uri.path, '/verify-email');
await tester.ensureVisible(
find.byKey(const ValueKey('verification-continue')),
);
await tester.tap(find.byKey(const ValueKey('verification-continue')));
await tester.pumpAndSettle();
final email = tester.widget<TextFormField>(
find.byKey(const ValueKey('email')),
);
expect(email.controller!.text, 'private-alias@example.com');
final password = tester.state<EditableTextState>(
find.descendant(
of: find.byKey(const ValueKey('password')),
matching: find.byType(EditableText),
),
);
expect(password.currentTextEditingValue.text, isEmpty);
expect(password.widget.focusNode.hasFocus, isTrue);
expect(router.routeInformationProvider.value.uri.toString(), '/');
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(
restored.state.toString(),
isNot(contains('private-alias@example.com')),
);
expect(restored.state.toString(), isNot(contains('demo-valid')));
await tester.enterText(
find.byKey(const ValueKey('email')),
'changed@example.com',
);
expect(email.controller!.text, 'changed@example.com');
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
},
);
}
testWidgets('unknown deep link redirects to canonical login URL', ( testWidgets('unknown deep link redirects to canonical login URL', (
tester, tester,
) async { ) async {
+153
View File
@@ -0,0 +1,153 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class DeliveryBackend extends BackendAuthServices {
DeliveryBackend(List<String> requests)
: super(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
requests.add(request.url.path);
return http.Response(
jsonEncode({
'data': request.url.path.endsWith('/signup')
? {'userId': 'customer'}
: {'accepted': true},
}),
202,
);
}),
);
int sends = 0;
bool failResend = true;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
sends++;
if (failResend) {
throw const VerificationException(VerificationError.internal);
}
return super.send(email: email, locale: locale);
}
}
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'accepted signup survives an unavailable resend service ($language)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final requests = <String>[];
final backend = DeliveryBackend(requests);
final analytics = DummyAnalyticsService();
await tester.pumpWidget(
SxchngeApp(
backend: backend,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
await tester.ensureVisible(signup);
await tester.tap(signup);
await tester.pumpAndSettle();
for (final entry in {
'name': 'Ada Lovelace',
'email': 'ada@example.com',
'password': 'password preserved',
'confirmation': 'password preserved',
}.entries) {
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
}
final submit = find.byKey(const ValueKey('submit'));
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('ada@example.com'), findsOneWidget);
expect(backend.sends, 0);
expect(requests, ['/api/v1/accounts/signup']);
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.uri.toString(), '/verification-sent');
expect(restored.state.toString(), isNot(contains('ada@example.com')));
expect(
analytics.events.where(
(event) => event.name == 'auth_signup_succeeded',
),
hasLength(1),
);
expect(
analytics.events.where(
(event) => event.name == 'auth_email_delivery_succeeded',
),
hasLength(1),
);
final resend = find.byKey(const ValueKey('verification-resend'));
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-error')),
findsOneWidget,
);
expect(backend.sends, 1);
expect(
tester
.widget<TextFormField>(
find.byKey(const ValueKey('verification-email')),
)
.controller!
.text,
'ada@example.com',
);
backend.failResend = false;
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(backend.sends, 2);
expect(requests.last, '/api/v1/accounts/verification/send');
expect(requests, hasLength(2));
expect(
analytics.events.map((event) => event.journeyId).toSet(),
hasLength(1),
);
expect(
analytics.events.map((event) => event.properties).toString(),
isNot(contains('ada@example.com')),
);
expect(tester.takeException(), isNull);
},
);
}
}
+39
View File
@@ -148,13 +148,38 @@ void main() {
await signIn(t); await signIn(t);
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect( expect(
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal), analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
isEmpty, isEmpty,
); );
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm'); await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget); expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
final login = analytics.events final login = analytics.events
.where((e) => e.kind == JourneyKind.login) .where((e) => e.kind == JourneyKind.login)
.toList(); .toList();
@@ -172,6 +197,20 @@ void main() {
await signIn(t); await signIn(t);
await tapFactor(t, 'two-factor-cancel'); await tapFactor(t, 'two-factor-cancel');
expect(find.byKey(const ValueKey('email')), findsOneWidget); expect(find.byKey(const ValueKey('email')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect( expect(
t t
@@ -6,6 +6,8 @@ class BackendDTO {
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>, (jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
); );
final Map<String, dynamic> data; final Map<String, dynamic> data;
bool get verified => data['verified'] == true;
String? get email => data['email'] as String?;
String get error => data['error'] as String? ?? 'unavailable'; String get error => data['error'] as String? ?? 'unavailable';
String get userId => String get userId =>
data['userId'] as String? ?? data['userId'] as String? ??
@@ -6,6 +6,7 @@ class AuthDTO {
} }
class VerificationDTO { class VerificationDTO {
const VerificationDTO({required this.simulated}); const VerificationDTO({required this.simulated, this.email});
final bool simulated; final bool simulated;
final String? email;
} }
@@ -5,9 +5,20 @@ import '../models/two_factor.dart';
import '../models/verification.dart'; import '../models/verification.dart';
abstract final class BackendMapper { abstract final class BackendMapper {
static AuthResult signup(BackendDTO dto) { static VerificationResult activation(BackendDTO dto) {
if (!dto.verified) {
throw const VerificationException(VerificationError.internal);
}
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
}
static AuthResult signup(BackendDTO dto, {required String email}) {
if (dto.userId.isEmpty) throw const FormatException('Missing user'); if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult(userId: dto.userId, isSimulated: false); return AuthResult(
userId: dto.userId,
isSimulated: false,
delivery: VerificationDelivery(email: email, isSimulated: false),
);
} }
static FactorChallenge challenge(BackendDTO dto, String id) { static FactorChallenge challenge(BackendDTO dto, String id) {
@@ -5,9 +5,15 @@ import '../models/verification.dart';
abstract final class AuthMapper { abstract final class AuthMapper {
static AuthResult fromDTO(AuthDTO dto) => static AuthResult fromDTO(AuthDTO dto) =>
AuthResult(userId: dto.userId, isSimulated: dto.simulated); AuthResult(userId: dto.userId, isSimulated: dto.simulated);
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
userId: dto.userId,
isSimulated: dto.simulated,
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
);
} }
abstract final class VerificationMapper { abstract final class VerificationMapper {
static VerificationResult fromDTO(VerificationDTO dto) => static VerificationResult fromDTO(VerificationDTO dto) =>
VerificationResult(isSimulated: dto.simulated); VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
} }
@@ -1,4 +1,5 @@
import 'two_factor.dart'; import 'two_factor.dart';
import 'verification.dart';
/// Ephemeral input: never persist, log, or include passwords in provider state. /// Ephemeral input: never persist, log, or include passwords in provider state.
class LoginRequest { class LoginRequest {
@@ -31,9 +32,11 @@ class AuthResult {
required this.userId, required this.userId,
required this.isSimulated, required this.isSimulated,
this.challenge, this.challenge,
this.delivery,
}); });
final String userId; final String userId;
final FactorChallenge? challenge; final FactorChallenge? challenge;
final VerificationDelivery? delivery;
final bool isSimulated; final bool isSimulated;
} }
@@ -11,21 +11,35 @@ enum VerificationStatus {
enum VerificationError { expired, invalid, internal, needsAssistance } enum VerificationError { expired, invalid, internal, needsAssistance }
/// An accepted email-queue operation, not proof of inbox delivery or activation.
class VerificationDelivery {
const VerificationDelivery({required this.email, required this.isSimulated});
final String email;
final bool isSimulated;
}
class VerificationException implements Exception { class VerificationException implements Exception {
const VerificationException(this.code); const VerificationException(this.code);
final VerificationError code; final VerificationError code;
} }
class VerificationResult { class VerificationResult {
const VerificationResult({required this.isSimulated}); const VerificationResult({required this.isSimulated, this.verifiedEmail});
final bool isSimulated; final bool isSimulated;
final String? verifiedEmail;
} }
class VerificationState { class VerificationState {
const VerificationState(this.status, {this.isSimulated = false, this.error}); const VerificationState(
this.status, {
this.isSimulated = false,
this.error,
this.result,
});
final VerificationStatus status; final VerificationStatus status;
final VerificationError? error; final VerificationError? error;
final bool isSimulated; final bool isSimulated;
final VerificationResult? result;
bool get isLoading => bool get isLoading =>
status == VerificationStatus.sending || status == VerificationStatus.sending ||
status == VerificationStatus.checking; status == VerificationStatus.checking;
@@ -4,13 +4,22 @@ import '../models/verification.dart';
import '../services/verification.dart'; import '../services/verification.dart';
class VerificationProvider extends ChangeNotifier { class VerificationProvider extends ChangeNotifier {
VerificationProvider(this.service); VerificationProvider(this.service, {VerificationDelivery? delivery})
: _state = _initialState(delivery);
final VerificationService service; final VerificationService service;
VerificationState _state = const VerificationState(VerificationStatus.idle); VerificationState _state;
VerificationState get state => _state; VerificationState get state => _state;
bool _disposed = false; bool _disposed = false;
int _generation = 0; int _generation = 0;
static VerificationState _initialState(VerificationDelivery? delivery) =>
delivery == null
? const VerificationState(VerificationStatus.idle)
: VerificationState(
VerificationStatus.sent,
isSimulated: delivery.isSimulated,
);
Future<void> send({required String email, required String locale}) => _run( Future<void> send({required String email, required String locale}) => _run(
() => service.send(email: email.trim(), locale: locale), () => service.send(email: email.trim(), locale: locale),
VerificationStatus.sending, VerificationStatus.sending,
@@ -35,7 +44,11 @@ class VerificationProvider extends ChangeNotifier {
try { try {
final result = await operation(); final result = await operation();
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
_state = VerificationState(success, isSimulated: result.isSimulated); _state = VerificationState(
success,
isSimulated: result.isSimulated,
result: success == VerificationStatus.verified ? result : null,
);
} catch (error) { } catch (error) {
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
final code = error is VerificationException final code = error is VerificationException
@@ -57,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void reset() { void reset({VerificationDelivery? delivery}) {
if (_disposed) return; if (_disposed) return;
++_generation; ++_generation;
_state = const VerificationState(VerificationStatus.idle); _state = _initialState(delivery);
notifyListeners(); notifyListeners();
} }
@@ -51,7 +51,6 @@ class BackendAuthServices
final _pending = <String, ({String token, String challengeId})>{}; final _pending = <String, ({String token, String challengeId})>{};
final _grants = <String, AuthSession>{}; final _grants = <String, AuthSession>{};
final _retryKeys = <String, String>{}; final _retryKeys = <String, String>{};
String? _queuedSignup;
AuthSession? _session; AuthSession? _session;
AuthSession? get session => _session; AuthSession? get session => _session;
@override @override
@@ -113,9 +112,8 @@ class BackendAuthServices
'locale': r.locale, 'locale': r.locale,
'idempotencyKey': key, 'idempotencyKey': key,
}); });
final result = BackendMapper.signup(dto); final result = BackendMapper.signup(dto, email: email);
_retryKeys.remove('signup:$email'); _retryKeys.remove('signup:$email');
_queuedSignup = email;
return result; return result;
} }
@@ -141,14 +139,6 @@ class BackendAuthServices
required String locale, required String locale,
}) async { }) async {
final normalized = email.trim().toLowerCase(); final normalized = email.trim().toLowerCase();
// Signup already queued this email. The existing confirmation page requests
// its initial delivery on mount; consume that receipt once.
if (_queuedSignup == normalized) {
_queuedSignup = null;
return VerificationMapper.fromDTO(
const VerificationDTO(simulated: false),
);
}
final key = _retryKeys.putIfAbsent('activation:$normalized', _key); final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
try { try {
await _post('verification/send', { await _post('verification/send', {
@@ -172,8 +162,10 @@ class BackendAuthServices
@override @override
Future<VerificationResult> confirm({required String token}) async { Future<VerificationResult> confirm({required String token}) async {
await _post('verification/confirm', {'token': token}, verification: true); final dto = await _post('verification/confirm', {
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false)); 'token': token,
}, verification: true);
return BackendMapper.activation(dto);
} }
@override @override
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
Future<AuthResult> signup(SignupRequest request) async { Future<AuthResult> signup(SignupRequest request) async {
await Future<void>.delayed(delay); await Future<void>.delayed(delay);
if (failure case final error?) throw AuthException(error); if (failure case final error?) throw AuthException(error);
return AuthMapper.fromDTO( return AuthMapper.signup(
const AuthDTO(userId: 'demo-user', simulated: true), const AuthDTO(userId: 'demo-user', simulated: true),
email: request.email.trim(),
); );
} }
} }
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
simulation: _simulation(loginService), simulation: _simulation(loginService),
); );
@override @override
Future<AuthResult> signup(SignupRequest request) => _run( Future<AuthResult> signup(SignupRequest request) async {
final result = await _run(
JourneyKind.signup, JourneyKind.signup,
JourneyStep.signup, JourneyStep.signup,
() => signupService.signup(request), () => signupService.signup(request),
(r) => r.isSimulated, (r) => r.isSimulated,
simulation: _simulation(signupService), simulation: _simulation(signupService),
); );
final delivery = result.delivery;
if (delivery != null) {
final operation = recorder.begin(
JourneyKind.verification,
JourneyStep.emailDelivery,
onboarding: true,
simulated: delivery.isSimulated,
);
recorder.finish(operation, simulated: delivery.isSimulated);
}
return result;
}
@override @override
Future<VerificationResult> send({ Future<VerificationResult> send({
required String email, required String email,
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({ const DummyVerificationService({
this.delay = const Duration(milliseconds: 600), this.delay = const Duration(milliseconds: 600),
this.sendFailure, this.sendFailure,
this.verifiedEmail = 'alex@example.com',
}); });
@override @override
bool get isSimulated => true; bool get isSimulated => true;
final Duration delay; final Duration delay;
final VerificationError? sendFailure; final VerificationError? sendFailure;
final String verifiedEmail;
@override @override
Future<VerificationResult> send({ Future<VerificationResult> send({
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
switch (token) { switch (token) {
case 'demo-valid': case 'demo-valid':
return VerificationMapper.fromDTO( return VerificationMapper.fromDTO(
const VerificationDTO(simulated: true), VerificationDTO(simulated: true, email: verifiedEmail),
); );
case 'demo-expired': case 'demo-expired':
throw const VerificationException(VerificationError.expired); throw const VerificationException(VerificationError.expired);
@@ -142,11 +142,11 @@ void main() {
.isSimulated, .isSimulated,
isTrue, isTrue,
); );
expect( final accepted = await const DummySignupService(delay: Duration.zero)
(await const DummySignupService(delay: Duration.zero).signup(signup)) .signup(signup);
.isSimulated, expect(accepted.isSimulated, isTrue);
isTrue, expect(accepted.delivery?.email, 'a@example.com');
); expect(accepted.delivery?.isSimulated, isTrue);
await expectLater( await expectLater(
const DummyLoginService( const DummyLoginService(
delay: Duration.zero, delay: Duration.zero,
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() { void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) => http.Response response(Map<String, Object?> data, [int status = 202]) =>
http.Response(jsonEncode({'data': data}), status); http.Response(jsonEncode({'data': data}), status);
test(
'activation maps only a confirmed response to the verified email',
() async {
var confirmed = true;
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/accounts/verification/confirm');
expect(jsonDecode(request.body), {'token': 'opaque-link'});
return response({
'verified': confirmed,
'email': 'alias@example.com',
});
}),
);
addTearDown(service.close);
final accepted = await service.confirm(token: 'opaque-link');
expect(accepted.verifiedEmail, 'alias@example.com');
expect(accepted.isSimulated, isFalse);
confirmed = false;
await expectLater(
service.confirm(token: 'opaque-link'),
throwsA(isA<VerificationException>()),
);
},
);
test(
'activation remains compatible with a verified response without email',
() async {
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((_) async => response({'verified': true})),
);
addTearDown(service.close);
expect(
(await service.confirm(token: 'opaque-link')).verifiedEmail,
isNull,
);
},
);
test( test(
'signup distinguishes support recovery and keeps the retry key', 'signup distinguishes support recovery and keeps the retry key',
() async { () async {
@@ -47,7 +89,9 @@ void main() {
expect(keys[0], keys[1]); expect(keys[0], keys[1]);
}, },
); );
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async { test(
'signup returns its receipt and every explicit resend reaches API',
() async {
final paths = <String>[]; final paths = <String>[];
final service = BackendAuthServices( final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'), baseUri: Uri.parse('https://api.example/api/v1'),
@@ -55,7 +99,10 @@ void main() {
client: MockClient((request) async { client: MockClient((request) async {
paths.add(request.url.path); paths.add(request.url.path);
if (request.url.path.endsWith('/signup')) { if (request.url.path.endsWith('/signup')) {
expect(jsonDecode(request.body)['password'], ' password preserved '); expect(
jsonDecode(request.body)['password'],
' password preserved ',
);
return response({'userId': 'user'}); return response({'userId': 'user'});
} }
return response({'accepted': true}); return response({'accepted': true});
@@ -71,11 +118,19 @@ void main() {
), ),
); );
expect(result.isSimulated, isFalse); expect(result.isSimulated, isFalse);
await service.send(email: 'user@example.com', locale: 'en'); expect(result.delivery?.email, 'user@example.com');
expect(result.delivery?.isSimulated, isFalse);
expect(paths, ['/api/v1/accounts/signup']); expect(paths, ['/api/v1/accounts/signup']);
await service.send(email: 'user@example.com', locale: 'en'); await service.send(email: 'user@example.com', locale: 'en');
expect(paths, [
'/api/v1/accounts/signup',
'/api/v1/accounts/verification/send',
]);
await service.send(email: 'user@example.com', locale: 'en');
expect(paths.last, '/api/v1/accounts/verification/send'); expect(paths.last, '/api/v1/accounts/verification/send');
}); expect(paths, hasLength(3));
},
);
test('login confirms backend session only after factor and consumes local grant once', () async { test('login confirms backend session only after factor and consumes local grant once', () async {
final now = DateTime.now(); final now = DateTime.now();
var verified = false; var verified = false;
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
} }
void main() { void main() {
test(
'accepted delivery initializes sent state without calling the service',
() async {
final service = PendingService();
final provider = VerificationProvider(
service,
delivery: const VerificationDelivery(
email: 'ada@example.com',
isSimulated: false,
),
);
addTearDown(provider.dispose);
expect(provider.state.status, VerificationStatus.sent);
expect(provider.state.isSimulated, isFalse);
expect(provider.state.result, isNull);
expect(service.calls, 0);
final resend = provider.send(email: 'ada@example.com', locale: 'en');
expect(provider.state.status, VerificationStatus.sending);
expect(service.calls, 1);
service.pending.completeError(
const VerificationException(VerificationError.internal),
);
await resend;
expect(provider.state.status, VerificationStatus.error);
expect(provider.state.result, isNull);
provider.reset();
expect(provider.state.status, VerificationStatus.idle);
},
);
for (final scenario in { for (final scenario in {
'demo-valid': VerificationStatus.verified, 'demo-valid': VerificationStatus.verified,
'demo-expired': VerificationStatus.expired, 'demo-expired': VerificationStatus.expired,
@@ -42,6 +72,12 @@ void main() {
addTearDown(provider.dispose); addTearDown(provider.dispose);
await provider.confirm(scenario.key); await provider.confirm(scenario.key);
expect(provider.state.status, scenario.value); expect(provider.state.status, scenario.value);
expect(
provider.state.result?.verifiedEmail,
scenario.value == VerificationStatus.verified
? 'alex@example.com'
: null,
);
expect( expect(
provider.state.isSimulated, provider.state.isSimulated,
scenario.value == VerificationStatus.verified, scenario.value == VerificationStatus.verified,
@@ -75,7 +111,23 @@ void main() {
service.pending.complete(const VerificationResult(isSimulated: true)); service.pending.complete(const VerificationResult(isSimulated: true));
await operation; await operation;
expect(provider.state.status, VerificationStatus.idle); expect(provider.state.status, VerificationStatus.idle);
expect(provider.state.result, isNull);
}); });
test(
'a later failed confirmation clears the previous verified identity',
() async {
final provider = VerificationProvider(
const DummyVerificationService(delay: Duration.zero),
);
addTearDown(provider.dispose);
await provider.confirm('demo-valid');
expect(provider.state.result?.verifiedEmail, 'alex@example.com');
final operation = provider.confirm('bad');
expect(provider.state.result, isNull);
await operation;
expect(provider.state.result, isNull);
},
);
test('dispose discards late exception', () async { test('dispose discards late exception', () async {
final service = PendingService(); final service = PendingService();
final provider = VerificationProvider(service); final provider = VerificationProvider(service);
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apk add --no-cache protobuf protobuf-dev make
WORKDIR /src WORKDIR /src
COPY Makefile ./ COPY Makefile ./
COPY backend ./backend COPY backend ./backend
RUN make generate-notifications RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod make generate-notifications
ARG MODULE ARG MODULE
WORKDIR /src/backend/${MODULE} WORKDIR /src/backend/${MODULE}
RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -o /out/service . RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -o /out/service .
+2
View File
@@ -119,6 +119,8 @@ services:
frontend: frontend:
image: sxchnge-local/frontend:dev image: sxchnge-local/frontend:dev
# Reap orphaned ssl_client children from BusyBox wget HTTPS health probes.
init: true
build: build:
context: . context: .
dockerfile: infra/images/frontend.Dockerfile dockerfile: infra/images/frontend.Dockerfile
+15
View File
@@ -5,6 +5,21 @@
https://localhost:8443 { https://localhost:8443 {
tls internal tls internal
# Record signup status/timing even when the API rejects a request before
# account creation. Drop the entire request and response headers so URLs,
# cookies, addresses and browser-provided values cannot enter access logs.
log signup {
format filter {
wrap json
fields {
request delete
resp_headers delete
user_id delete
}
}
}
@otherRequests not path /api/v1/accounts/signup
log_skip @otherRequests
root * /srv root * /srv
handle /api/* { handle /api/* {
reverse_proxy bff:8082 reverse_proxy bff:8082
+7 -3
View File
@@ -18,10 +18,14 @@ messaging:
reconnect_wait: 2 reconnect_wait: 2
templates: "/etc/sxchnge/templates.json" templates: "/etc/sxchnge/templates.json"
email: email:
driver: resend driver: smtp
settings: settings:
api_key_env: EMAIL_API_KEY host: mail.sendico.io
sender_address: "" # Set your verified Resend sender here, then run make build. port: 465
username: no-reply@sendico.io
password_env: SMTP_PASSWORD
network_timeout: 10
sender_address: no-reply@sendico.io
sender_name: "SXCHNGE" sender_name: "SXCHNGE"
# Use replicas: 3 and environment: production for a three-node NATS cluster. # Use replicas: 3 and environment: production for a three-node NATS cluster.
# mode: ops validates resources without modifying them; app provisions them. # mode: ops validates resources without modifying them; app provisions them.
+8 -8
View File
@@ -61,7 +61,7 @@ provision_agents() {
mkdir -p "$directory"; chmod 755 "$directory" mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in case "$service" in
bff) paths='services/bff/jwt';; bff) paths='services/bff/jwt';;
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';; notifications) paths='infrastructure/nats/auth services/notifications/email/smtp';;
nats) paths=infrastructure/nats/auth;; nats) paths=infrastructure/nats/auth;;
sendico) paths=services/bff/sendico;; sendico) paths=services/bff/sendico;;
esac esac
@@ -81,7 +81,7 @@ provision_agents() {
if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then if [ "$service" != sendico ] && ! cmp -s "infra/local/vault/$service.hcl" "$directory/config.hcl"; then
cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444 cat "infra/local/vault/$service.hcl" | write "$directory/config.hcl" 444
if compose ps --status running --services "$service" | grep -qx "$service"; then if compose ps --status running --services "$service" | grep -qx "$service"; then
compose up -d --no-deps --force-recreate --wait --wait-timeout 120 "$service" recreate_services 120 "$service"
fi fi
fi fi
done done
@@ -105,13 +105,12 @@ up() {
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path" random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
fi fi
done done
printf '{"api_key":""}' | ensure services/notifications/email/resend
provision_mongo provision_mongo
provision_agents provision_agents
compose up -d --wait --wait-timeout 120 nats compose up -d --wait --wait-timeout 120 nats
retire_secrets retire_secrets
normalize_email_secret normalize_email_secret
kv services/notifications/email/resend | email_valid || fail 'Vault kv/services/notifications/email/resend needs api_key. Run make email; configure the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' kv services/notifications/email/smtp | smtp_valid || fail 'Vault kv/services/notifications/email/smtp needs the mailbox password. Run make email.'
compose up -d --wait --wait-timeout 180 compose up -d --wait --wait-timeout 180
export_ca export_ca
cat <<'MESSAGE' cat <<'MESSAGE'
@@ -134,7 +133,7 @@ MESSAGE
} }
email() { email() {
existing existing
printf 'Resend API key (hidden): ' >&2 printf 'SMTP mailbox password (hidden): ' >&2
tty_state=$(stty -g) tty_state=$(stty -g)
trap 'stty "$tty_state"; cleanup' EXIT trap 'stty "$tty_state"; cleanup' EXIT
stty -echo stty -echo
@@ -143,7 +142,8 @@ email() {
printf '\n' >&2 printf '\n' >&2
[ -n "$key" ] || fail 'A key is required' [ -n "$key" ] || fail 'A key is required'
printf '%s' "$key" > "$WORK/email-key" printf '%s' "$key" > "$WORK/email-key"
normalize_email_secret jq -n --rawfile password "$WORK/email-key" '{password:$password}' > "$WORK/smtp-secret"
jq -n --rawfile key "$WORK/email-key" '{api_key:$key}' | put services/notifications/email/resend smtp_valid < "$WORK/smtp-secret" || fail 'Invalid SMTP mailbox password'
echo 'API key saved in Vault. Set the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' put services/notifications/email/smtp < "$WORK/smtp-secret"
echo 'SMTP password saved in Vault. The notifications Agent reloads it automatically.'
} }
+14
View File
@@ -21,6 +21,19 @@ compose() {
docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@" docker compose --project-directory . -f infra/local/compose.yml --env-file "$STATE/images.env" "$@"
fi fi
} }
# A recreated BFF has a new container ID. Recreate its namespace-sharing session
# in the same Compose operation so neither waits on the other's old namespace.
recreate_services() {
recreate_timeout=$1; shift
case " $* " in
*' bff '*)
if [ -f "$STATE/sendico/settings.env" ]; then
case " $* " in *' sendico '*) ;; *) set -- "$@" sendico;; esac
fi
;;
esac
compose up -d --no-deps --force-recreate --wait --wait-timeout "$recreate_timeout" "$@"
}
write() { write() {
target=$1; mode=${2:-600} target=$1; mode=${2:-600}
mkdir -p "$(dirname "$target")" mkdir -p "$(dirname "$target")"
@@ -64,6 +77,7 @@ ensure() (
mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; } mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; }
admin_auth() { kv infrastructure/mongo/bootstrap | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; } admin_auth() { kv infrastructure/mongo/bootstrap | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; }
email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S"))' >/dev/null; } email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S"))' >/dev/null; }
smtp_valid() { jq -e 'keys==["password"] and (.password|type=="string" and length>0 and (explode|all(.>0 and .!=10 and .!=13)))' >/dev/null; }
export_ca() { export_ca() {
compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644 compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644
} }
+4 -2
View File
@@ -10,7 +10,9 @@ rotation() (
for pid in $workers; do wait "$pid" || true; done for pid in $workers; do wait "$pid" || true; done
restore_failed=0 restore_failed=0
vault database/roles/bff POST < "$WORK/original-role" >/dev/null || { echo 'ERROR: restore BFF role TTL manually' >&2; restore_failed=1; } vault database/roles/bff POST < "$WORK/original-role" >/dev/null || { echo 'ERROR: restore BFF role TTL manually' >&2; restore_failed=1; }
for service in frontend bff; do restore_services='frontend bff'
if [ -f "$STATE/sendico/settings.env" ]; then restore_services='frontend sendico bff'; fi
for service in $restore_services; do
if ! grep -qx "$service" "$WORK/running"; then compose stop "$service"; compose rm -f "$service"; fi if ! grep -qx "$service" "$WORK/running"; then compose stop "$service"; compose rm -f "$service"; fi
done done
return "$restore_failed" return "$restore_failed"
@@ -19,7 +21,7 @@ rotation() (
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
jq '.+{default_ttl:"12s",max_ttl:"24s"}' "$WORK/original-role" | vault database/roles/bff POST >/dev/null jq '.+{default_ttl:"12s",max_ttl:"24s"}' "$WORK/original-role" | vault database/roles/bff POST >/dev/null
compose up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend recreate_services 90 bff frontend
export_ca export_ca
https -f https://localhost:5126/ > "$WORK/index" https -f https://localhost:5126/ > "$WORK/index"
grep -q flutter_bootstrap "$WORK/index" || fail 'Frontend bundle is not served' grep -q flutter_bootstrap "$WORK/index" || fail 'Frontend bundle is not served'
+43 -1
View File
@@ -4,10 +4,48 @@ set -o pipefail
# Shell syntax and static analysis cover the actual container shell scripts. # Shell syntax and static analysis cover the actual container shell scripts.
shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh shellcheck -s ash -x -S warning infra/local/scripts/dispatch.sh infra/local/scripts/test.sh
shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh shellcheck -x -S warning infra/local/tools.sh infra/local/scripts/start-service.sh infra/local/mongo/start.sh infra/local/nats/start.sh infra/local/vault/start.sh
shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh shellcheck -s ash -x -S warning infra/local/sendico/session.sh infra/local/sendico/check.sh infra/local/sendico/network.sh
. infra/local/scripts/common.sh . infra/local/scripts/common.sh
. infra/local/scripts/images.sh . infra/local/scripts/images.sh
. infra/local/scripts/bootstrap.sh . infra/local/scripts/bootstrap.sh
# A transient DNS failure must reset the recovery counter, while sustained loss
# must terminate only the owning session process so Docker can reattach it.
(
. infra/local/sendico/network.sh
network_calls=0; network_recovered=0
sleep() { :; }
timeout() { [ "$1" = 5 ] || exit 1; shift; "$@"; }
getent() {
[ "$*" = 'hosts vault' ] || exit 1
network_calls=$((network_calls+1))
[ "$network_calls" = 3 ]
}
kill() {
[ "$*" = '-TERM 1234' ] && [ "$network_calls" = 6 ] || exit 1
network_recovered=1
}
watch_network 1234 2> "$WORK/network-recovery"
[ "$network_recovered" = 1 ] || exit 1
grep -q 'reattach BFF network' "$WORK/network-recovery"
for invalid_parent in '' 0 00 invalid; do
if watch_network "$invalid_parent"; then exit 1; fi
done
)
echo 'PASS: session network recovery tolerates transient DNS failures and targets its parent after sustained loss'
(
STATE="$WORK/recreate-state"; mkdir -p "$STATE/sendico"
compose() { printf '%s\n' "$*" > "$WORK/recreate-command"; }
recreate_services 90 bff frontend
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend' "$WORK/recreate-command"
touch "$STATE/sendico/settings.env"
recreate_services 90 bff frontend
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend sendico' "$WORK/recreate-command"
recreate_services 120 bff sendico
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 120 bff sendico' "$WORK/recreate-command"
recreate_services 120 notifications
grep -qx 'up -d --no-deps --force-recreate --wait --wait-timeout 120 notifications' "$WORK/recreate-command"
)
echo 'PASS: BFF recreation pairs its configured session exactly once and preserves unrelated service scope'
# Offline fixtures exercise stable selection, reject prereleases, and preserve pins. # Offline fixtures exercise stable selection, reject prereleases, and preserve pins.
get() { get() {
case "$1" in case "$1" in
@@ -29,6 +67,10 @@ if images >/dev/null 2>&1; then fail 'Offline resolution succeeded'; fi
[ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins' [ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins'
if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi
printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid
printf '{"password":"fixture-only"}' | smtp_valid
for invalid in '{"password":""}' '{"password":"bad\nvalue"}' '{"password":"bad\u0000value"}' '{"password":"fixture","username":"not-a-secret"}'; do
if printf '%s' "$invalid" | smtp_valid; then fail 'Invalid SMTP secret accepted'; fi
done
# Initialized Vault without matching local keys must fail without initializing it again. # Initialized Vault without matching local keys must fail without initializing it again.
vault() { printf '{"initialized":true}'; } vault() { printf '{"initialized":true}'; }
if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi
+2 -2
View File
@@ -61,11 +61,11 @@ layout() {
if [ -f "$STATE/sendico/settings.env" ]; then if [ -f "$STATE/sendico/settings.env" ]; then
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration' kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
fi fi
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault' kv services/notifications/email/smtp | jq -e 'keys==["password"]' >/dev/null || fail 'SMTP secret contains non-secret configuration'
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old" [ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
done done
for path in services/bff/jwt services/notifications/email/resend infrastructure/nats/auth infrastructure/mongo/bootstrap; do for path in services/bff/jwt services/notifications/email/smtp infrastructure/nats/auth infrastructure/mongo/bootstrap; do
vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path" vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path"
done done
echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent' echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent'
+3
View File
@@ -12,10 +12,13 @@ services:
restart: unless-stopped restart: unless-stopped
# Keep the adapter's loopback-only HTTP rule; forward to the public stand API. # Keep the adapter's loopback-only HTTP rule; forward to the public stand API.
network_mode: service:bff network_mode: service:bff
depends_on:
bff: {condition: service_started, restart: true}
entrypoint: [/bin/sh, /etc/sendico/session.sh] entrypoint: [/bin/sh, /etc/sendico/session.sh]
env_file: ./.local/dev/sendico/settings.env env_file: ./.local/dev/sendico/settings.env
volumes: volumes:
- ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro - ./infra/local/sendico/session.sh:/etc/sendico/session.sh:ro
- ./infra/local/sendico/network.sh:/etc/sendico/network.sh:ro
- ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro - ./infra/local/sendico/check.sh:/etc/sendico/check.sh:ro
- ./.local/dev/agents/sendico:/etc/agent:ro - ./.local/dev/agents/sendico:/etc/agent:ro
- sendico-session:/run/sendico - sendico-session:/run/sendico
+20
View File
@@ -0,0 +1,20 @@
# Restarting the network-owning BFF can strand this sidecar in its old namespace.
# Exiting the session process lets Docker rejoin the BFF's current namespace.
watch_network() {
network_parent=$1
case "$network_parent" in ''|*[!0-9]*) return 1;; esac
[ "$network_parent" -gt 0 ] || return 1
network_failures=0
while sleep 10; do
if timeout 5 getent hosts vault >/dev/null 2>&1; then
network_failures=0
continue
fi
network_failures=$((network_failures+1))
if [ "$network_failures" -ge 3 ]; then
echo 'Local Sendico network DNS unavailable; restarting session container to reattach BFF network.' >&2
kill -TERM "$network_parent" 2>/dev/null || true
return
fi
done
}
+15 -4
View File
@@ -1,13 +1,24 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
umask 077 umask 077
# shellcheck source=infra/local/sendico/network.sh
. /etc/sendico/network.sh
mkdir -p /private /run/sendico mkdir -p /private /run/sendico
chmod 755 /run/sendico chmod 755 /run/sendico
cd /private cd /private
bridge=''; monitor=''; delay=''
cleanup() {
for child in "$bridge" "$monitor" "$delay"; do
if [ -n "$child" ]; then kill "$child" 2>/dev/null || true; fi
done
}
trap cleanup EXIT
trap 'exit 0' INT TERM
socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 & socat TCP4-LISTEN:18081,bind=127.0.0.1,reuseaddr,fork TCP4:host.docker.internal:8080 &
bridge=$! bridge=$!
trap 'kill "$bridge" 2>/dev/null || true' EXIT session_pid=$$
trap 'exit 0' INT TERM (trap - EXIT INT TERM; watch_network "$session_pid") &
monitor=$!
session() { session() {
jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \ jq -n --rawfile role /etc/agent/role-id --rawfile secret /etc/agent/secret-id \
@@ -36,10 +47,10 @@ session() {
while :; do while :; do
if session; then if session; then
echo 'Local Sendico session ready; next renewal in 20 minutes.' echo 'Local Sendico session ready; next renewal in 20 minutes.'
sleep 1200 & wait $! sleep 1200 & delay=$!; wait "$delay"
else else
echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2 echo 'Local Sendico session unavailable; check stand health and integration credentials. Retrying in 5 minutes.' >&2
rm -f request.json response.json token vault.header vault.json credentials.json login.json rm -f request.json response.json token vault.header vault.json credentials.json login.json
sleep 300 & wait $! sleep 300 & delay=$!; wait "$delay"
fi fi
done done
+2 -2
View File
@@ -22,8 +22,8 @@ env_template "SXCHNGE_NATS_URL" {
env_template "EMAIL_API_KEY" { env_template "SMTP_PASSWORD" {
contents = "{{ with secret \"kv/data/services/notifications/email/resend\" }}{{ .Data.data.api_key }}{{ end }}" contents = "{{ with secret \"kv/data/services/notifications/email/smtp\" }}{{ .Data.data.password }}{{ end }}"
error_on_missing_key = true error_on_missing_key = true
} }