Projeto bot pontos
This commit is contained in:
429
backend/internal/config/config.go
Normal file
429
backend/internal/config/config.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// Package config carrega a configuração a partir de variáveis de ambiente.
|
||||
//
|
||||
// Segredos aceitam a variante <NOME>_FILE, que aponta para um arquivo
|
||||
// (ex.: Docker secrets em /run/secrets/...).
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
LogLevel string
|
||||
DefaultTimezone string
|
||||
DB DB
|
||||
HTTP HTTP
|
||||
Worker Worker
|
||||
Jobs Jobs
|
||||
PontoMais PontoMais
|
||||
Crypto Crypto
|
||||
Telegram Telegram
|
||||
Holidays Holidays
|
||||
Web Web
|
||||
DefaultWindow domain.QueryWindow // padrões gravados em app_settings na 1ª subida
|
||||
}
|
||||
|
||||
type DB struct {
|
||||
Host string
|
||||
Port int
|
||||
Name string
|
||||
User string
|
||||
Password string
|
||||
MaxConns int32
|
||||
}
|
||||
|
||||
// DSN monta a URL de conexão. A rede entre containers é interna, por isso sslmode=disable.
|
||||
func (d DB) DSN() string {
|
||||
query := url.Values{}
|
||||
query.Set("sslmode", "disable")
|
||||
query.Set("application_name", "botpontos")
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(d.User, d.Password),
|
||||
Host: fmt.Sprintf("%s:%d", d.Host, d.Port),
|
||||
Path: "/" + d.Name,
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
type HTTP struct {
|
||||
Addr string
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
HealthAddr string
|
||||
LeaderRetry time.Duration
|
||||
}
|
||||
|
||||
// Jobs reúne os parâmetros dos ciclos do worker (ARQUITETURA.md, seções 8.2 e 9).
|
||||
// Os horários da janela de consulta ficam no banco (app_settings), não aqui.
|
||||
type Jobs struct {
|
||||
ReconciliationInterval time.Duration
|
||||
ReconciliationDeadline time.Duration
|
||||
ReconciliationWorkers int
|
||||
DispatchInterval time.Duration
|
||||
MaintenanceInterval time.Duration
|
||||
LateTolerance time.Duration
|
||||
SendingTimeout time.Duration
|
||||
SlowCheckInterval time.Duration
|
||||
ExitConfirmFrom time.Duration
|
||||
ExitConfirmTo time.Duration
|
||||
APIFailureThreshold int
|
||||
RetentionAudit time.Duration
|
||||
RetentionNotifications time.Duration
|
||||
RetentionSessions time.Duration
|
||||
RetentionHour int
|
||||
}
|
||||
|
||||
type PontoMais struct {
|
||||
AuthURL string
|
||||
HistoryURL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Crypto struct {
|
||||
Key string
|
||||
KeyVersion int
|
||||
PreviousKey string
|
||||
PreviousKeyVersion int
|
||||
}
|
||||
|
||||
type Telegram struct {
|
||||
Token string
|
||||
PollTimeout int // segundos
|
||||
}
|
||||
|
||||
type Holidays struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Location string
|
||||
}
|
||||
|
||||
type Web struct {
|
||||
PublicURL string
|
||||
CookieSecure bool
|
||||
SessionDuration time.Duration
|
||||
SessionIdle time.Duration
|
||||
LoginLock time.Duration
|
||||
LoginMaxFailures int
|
||||
TrustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
return load(os.Getenv)
|
||||
}
|
||||
|
||||
func load(getenv func(string) string) (Config, error) {
|
||||
r := &reader{getenv: getenv}
|
||||
cfg := Config{
|
||||
LogLevel: r.str("BP_LOG_LEVEL", "info"),
|
||||
DefaultTimezone: r.str("BP_DEFAULT_TIMEZONE", domain.DefaultTimezone),
|
||||
DB: DB{
|
||||
Host: r.str("BP_DB_HOST", "postgres"),
|
||||
Port: r.integer("BP_DB_PORT", 5432),
|
||||
Name: r.str("BP_DB_NAME", "botpontos"),
|
||||
User: r.str("BP_DB_USER", ""),
|
||||
Password: r.secret("BP_DB_PASSWORD"),
|
||||
MaxConns: int32(r.integer("BP_DB_MAX_CONNS", 10)),
|
||||
},
|
||||
HTTP: HTTP{
|
||||
Addr: r.str("BP_HTTP_ADDR", ":8080"),
|
||||
},
|
||||
Worker: Worker{
|
||||
HealthAddr: r.str("BP_WORKER_HEALTH_ADDR", ":8081"),
|
||||
LeaderRetry: r.duration("BP_WORKER_LEADER_RETRY", 10*time.Second),
|
||||
},
|
||||
Jobs: Jobs{
|
||||
ReconciliationInterval: r.duration("BP_RECONCILIATION_INTERVAL", 15*time.Minute),
|
||||
ReconciliationDeadline: r.duration("BP_RECONCILIATION_DEADLINE", 5*time.Minute),
|
||||
ReconciliationWorkers: r.integer("BP_RECONCILIATION_WORKERS", 4),
|
||||
DispatchInterval: r.duration("BP_DISPATCH_INTERVAL", 2*time.Minute),
|
||||
MaintenanceInterval: r.duration("BP_MAINTENANCE_INTERVAL", 5*time.Minute),
|
||||
LateTolerance: r.duration("BP_LATE_TOLERANCE", 10*time.Minute),
|
||||
SendingTimeout: r.duration("BP_SENDING_TIMEOUT", 5*time.Minute),
|
||||
SlowCheckInterval: r.duration("BP_SLOW_CHECK_INTERVAL", 60*time.Minute),
|
||||
APIFailureThreshold: r.integer("BP_API_FAILURE_THRESHOLD", 3),
|
||||
RetentionAudit: r.duration("BP_RETENTION_AUDIT", 365*24*time.Hour),
|
||||
RetentionNotifications: r.duration("BP_RETENTION_NOTIFICATIONS", 90*24*time.Hour),
|
||||
RetentionSessions: r.duration("BP_RETENTION_SESSIONS", 7*24*time.Hour),
|
||||
RetentionHour: r.integer("BP_RETENTION_HOUR", 3),
|
||||
},
|
||||
PontoMais: PontoMais{
|
||||
AuthURL: r.str("BP_PONTOMAIS_AUTH_URL", "https://api.pontomais.com.br/api/auth/sign_in"),
|
||||
HistoryURL: r.str("BP_PONTOMAIS_HISTORY_URL", "https://api.pontomais.com.br/api/time_cards/current/last_cached"),
|
||||
Timeout: r.duration("BP_PONTOMAIS_TIMEOUT", 10*time.Second),
|
||||
},
|
||||
Crypto: Crypto{
|
||||
Key: r.secret("BP_ENCRYPTION_KEY"),
|
||||
KeyVersion: r.integer("BP_ENCRYPTION_KEY_VERSION", 1),
|
||||
PreviousKey: r.secret("BP_ENCRYPTION_PREVIOUS_KEY"),
|
||||
PreviousKeyVersion: r.integer("BP_ENCRYPTION_PREVIOUS_KEY_VERSION", 0),
|
||||
},
|
||||
Telegram: Telegram{
|
||||
Token: r.secret("BP_TELEGRAM_TOKEN"),
|
||||
PollTimeout: r.integer("BP_TELEGRAM_POLL_TIMEOUT", 25),
|
||||
},
|
||||
Holidays: Holidays{
|
||||
BaseURL: r.str("BP_HOLIDAY_API_BASE_URL", "https://feriadosapi.com/api/v1"),
|
||||
APIKey: r.secret("BP_HOLIDAY_API_KEY"),
|
||||
Location: r.str("BP_HOLIDAY_LOCATION", "3106200"),
|
||||
},
|
||||
Web: Web{
|
||||
PublicURL: strings.TrimRight(r.str("BP_PUBLIC_URL", ""), "/"),
|
||||
CookieSecure: r.boolean("BP_COOKIE_SECURE", true),
|
||||
SessionDuration: r.duration("BP_SESSION_DURATION", 8*time.Hour),
|
||||
SessionIdle: r.duration("BP_SESSION_IDLE", 30*time.Minute),
|
||||
LoginLock: r.duration("BP_LOGIN_LOCK", 15*time.Minute),
|
||||
LoginMaxFailures: r.integer("BP_LOGIN_MAX_FAILURES", 5),
|
||||
TrustedProxies: r.prefixes("BP_TRUSTED_PROXIES", "127.0.0.0/8,172.16.0.0/12"),
|
||||
},
|
||||
}
|
||||
from, to := r.durationRange("BP_EXIT_CONFIRM_WINDOW", 20*time.Minute, 5*time.Minute)
|
||||
cfg.Jobs.ExitConfirmFrom, cfg.Jobs.ExitConfirmTo = from, to
|
||||
|
||||
cfg.DefaultWindow = domain.QueryWindow{
|
||||
Start: r.clock("BP_QUERY_WINDOW_START", "11:30"),
|
||||
End: r.clock("BP_QUERY_WINDOW_END", "19:00"),
|
||||
DayCheckDeadline: r.clock("BP_DAY_CHECK_DEADLINE", "11:45"),
|
||||
NoPointsRecheckUntil: r.clock("BP_NO_POINTS_RECHECK_UNTIL", "15:00"),
|
||||
}
|
||||
|
||||
errs := append(r.errs, cfg.validate()...)
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c Config) validate() []error {
|
||||
var errs []error
|
||||
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(c.LogLevel)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("BP_LOG_LEVEL: valor inválido %q (use debug, info, warn ou error)", c.LogLevel))
|
||||
}
|
||||
if _, err := time.LoadLocation(c.DefaultTimezone); err != nil {
|
||||
errs = append(errs, fmt.Errorf("BP_DEFAULT_TIMEZONE: fuso inválido %q", c.DefaultTimezone))
|
||||
}
|
||||
|
||||
if c.DB.User == "" {
|
||||
errs = append(errs, errors.New("BP_DB_USER: obrigatório"))
|
||||
}
|
||||
if c.DB.Password == "" {
|
||||
errs = append(errs, errors.New("BP_DB_PASSWORD ou BP_DB_PASSWORD_FILE: obrigatório"))
|
||||
}
|
||||
if c.DB.Port <= 0 || c.DB.Port > 65535 {
|
||||
errs = append(errs, fmt.Errorf("BP_DB_PORT: porta inválida %d", c.DB.Port))
|
||||
}
|
||||
if c.DB.MaxConns <= 0 {
|
||||
errs = append(errs, errors.New("BP_DB_MAX_CONNS: precisa ser maior que zero"))
|
||||
}
|
||||
|
||||
durations := []struct {
|
||||
name string
|
||||
value time.Duration
|
||||
}{
|
||||
{"BP_WORKER_LEADER_RETRY", c.Worker.LeaderRetry},
|
||||
{"BP_RECONCILIATION_INTERVAL", c.Jobs.ReconciliationInterval},
|
||||
{"BP_RECONCILIATION_DEADLINE", c.Jobs.ReconciliationDeadline},
|
||||
{"BP_DISPATCH_INTERVAL", c.Jobs.DispatchInterval},
|
||||
{"BP_MAINTENANCE_INTERVAL", c.Jobs.MaintenanceInterval},
|
||||
{"BP_LATE_TOLERANCE", c.Jobs.LateTolerance},
|
||||
{"BP_SENDING_TIMEOUT", c.Jobs.SendingTimeout},
|
||||
{"BP_SLOW_CHECK_INTERVAL", c.Jobs.SlowCheckInterval},
|
||||
{"BP_RETENTION_AUDIT", c.Jobs.RetentionAudit},
|
||||
{"BP_RETENTION_NOTIFICATIONS", c.Jobs.RetentionNotifications},
|
||||
{"BP_RETENTION_SESSIONS", c.Jobs.RetentionSessions},
|
||||
{"BP_PONTOMAIS_TIMEOUT", c.PontoMais.Timeout},
|
||||
{"BP_SESSION_DURATION", c.Web.SessionDuration},
|
||||
{"BP_SESSION_IDLE", c.Web.SessionIdle},
|
||||
{"BP_LOGIN_LOCK", c.Web.LoginLock},
|
||||
}
|
||||
for _, d := range durations {
|
||||
if d.value <= 0 {
|
||||
errs = append(errs, fmt.Errorf("%s: precisa ser maior que zero", d.name))
|
||||
}
|
||||
}
|
||||
|
||||
if c.Jobs.ReconciliationInterval > 0 && time.Hour%c.Jobs.ReconciliationInterval != 0 {
|
||||
errs = append(errs, errors.New("BP_RECONCILIATION_INTERVAL: precisa dividir 1 hora (ex.: 5m, 10m, 15m, 30m)"))
|
||||
}
|
||||
if c.Jobs.SlowCheckInterval > 0 && c.Jobs.ReconciliationInterval > 0 &&
|
||||
c.Jobs.SlowCheckInterval%c.Jobs.ReconciliationInterval != 0 {
|
||||
errs = append(errs, errors.New("BP_SLOW_CHECK_INTERVAL: precisa ser múltiplo de BP_RECONCILIATION_INTERVAL"))
|
||||
}
|
||||
if c.Jobs.ExitConfirmFrom-c.Jobs.ExitConfirmTo < c.Jobs.ReconciliationInterval {
|
||||
errs = append(errs, errors.New("BP_EXIT_CONFIRM_WINDOW: a janela precisa ter pelo menos o intervalo de reconciliação"))
|
||||
}
|
||||
if c.Jobs.APIFailureThreshold < 1 {
|
||||
errs = append(errs, errors.New("BP_API_FAILURE_THRESHOLD: precisa ser pelo menos 1"))
|
||||
}
|
||||
if c.Jobs.ReconciliationWorkers < 1 {
|
||||
errs = append(errs, errors.New("BP_RECONCILIATION_WORKERS: precisa ser pelo menos 1"))
|
||||
}
|
||||
if c.Jobs.RetentionHour < 0 || c.Jobs.RetentionHour > 23 {
|
||||
errs = append(errs, errors.New("BP_RETENTION_HOUR: precisa estar entre 0 e 23"))
|
||||
}
|
||||
if c.Crypto.KeyVersion < 1 || c.Crypto.KeyVersion > 32767 {
|
||||
errs = append(errs, errors.New("BP_ENCRYPTION_KEY_VERSION: precisa estar entre 1 e 32767"))
|
||||
}
|
||||
if c.Web.LoginMaxFailures < 1 {
|
||||
errs = append(errs, errors.New("BP_LOGIN_MAX_FAILURES: precisa ser pelo menos 1"))
|
||||
}
|
||||
if c.Telegram.PollTimeout < 1 || c.Telegram.PollTimeout > 30 {
|
||||
errs = append(errs, errors.New("BP_TELEGRAM_POLL_TIMEOUT: precisa estar entre 1 e 30 segundos"))
|
||||
}
|
||||
if c.Crypto.PreviousKey != "" && (c.Crypto.PreviousKeyVersion < 1 || c.Crypto.PreviousKeyVersion > 32767) {
|
||||
errs = append(errs, errors.New("BP_ENCRYPTION_PREVIOUS_KEY_VERSION: precisa estar entre 1 e 32767"))
|
||||
}
|
||||
if c.Web.PublicURL != "" {
|
||||
if u, err := url.Parse(c.Web.PublicURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
errs = append(errs, fmt.Errorf("BP_PUBLIC_URL: URL inválida %q (ex.: https://botpontos.lan)", c.Web.PublicURL))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// RequireCrypto confere a chave de criptografia (API, worker e CLI de credenciais).
|
||||
func (c Config) RequireCrypto() error {
|
||||
if len(c.Crypto.Key) < 32 {
|
||||
return errors.New("BP_ENCRYPTION_KEY ou BP_ENCRYPTION_KEY_FILE: obrigatório, com pelo menos 32 caracteres")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireTelegram confere o token do bot (worker).
|
||||
func (c Config) RequireTelegram() error {
|
||||
if c.Telegram.Token == "" {
|
||||
return errors.New("BP_TELEGRAM_TOKEN ou BP_TELEGRAM_TOKEN_FILE: obrigatório para o worker")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type reader struct {
|
||||
getenv func(string) string
|
||||
errs []error
|
||||
}
|
||||
|
||||
func (r *reader) str(key, fallback string) string {
|
||||
if v := strings.TrimSpace(r.getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (r *reader) integer(key string, fallback int) int {
|
||||
v := strings.TrimSpace(r.getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: número inválido %q", key, v))
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *reader) boolean(key string, fallback bool) bool {
|
||||
v := strings.TrimSpace(r.getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: use true ou false, veio %q", key, v))
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (r *reader) duration(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(r.getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: duração inválida %q (ex.: 15m, 2m, 10s)", key, v))
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// durationRange lê "A..B" (ex.: "20m..5m").
|
||||
func (r *reader) durationRange(key string, fromDefault, toDefault time.Duration) (time.Duration, time.Duration) {
|
||||
v := strings.TrimSpace(r.getenv(key))
|
||||
if v == "" {
|
||||
return fromDefault, toDefault
|
||||
}
|
||||
parts := strings.Split(v, "..")
|
||||
if len(parts) != 2 {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: use o formato 20m..5m, veio %q", key, v))
|
||||
return fromDefault, toDefault
|
||||
}
|
||||
from, err1 := time.ParseDuration(strings.TrimSpace(parts[0]))
|
||||
to, err2 := time.ParseDuration(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil || to < 0 || from <= to {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: intervalo inválido %q (o primeiro valor precisa ser maior)", key, v))
|
||||
return fromDefault, toDefault
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
func (r *reader) clock(key, fallback string) domain.ClockTime {
|
||||
v := r.str(key, fallback)
|
||||
c, err := domain.ParseClock(v)
|
||||
if err != nil {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: %w", key, err))
|
||||
return domain.MustParseClock(fallback)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// prefixes lê uma lista de CIDRs ou IPs separados por vírgula.
|
||||
func (r *reader) prefixes(key, fallback string) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, item := range strings.Split(r.str(key, fallback), ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if p, err := netip.ParsePrefix(item); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
continue
|
||||
}
|
||||
if a, err := netip.ParseAddr(item); err == nil {
|
||||
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||
continue
|
||||
}
|
||||
r.errs = append(r.errs, fmt.Errorf("%s: endereço ou CIDR inválido %q", key, item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// secret lê <key> ou, se vazio, o arquivo apontado por <key>_FILE.
|
||||
func (r *reader) secret(key string) string {
|
||||
if v := strings.TrimSpace(r.getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
path := strings.TrimSpace(r.getenv(key + "_FILE"))
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
r.errs = append(r.errs, fmt.Errorf("%s_FILE: %w", key, err))
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(content))
|
||||
}
|
||||
111
backend/internal/config/config_test.go
Normal file
111
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func env(values map[string]string) func(string) string {
|
||||
return func(key string) string { return values[key] }
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := load(env(map[string]string{
|
||||
"BP_DB_USER": "botpontos_app",
|
||||
"BP_DB_PASSWORD": "segredo",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("erro inesperado: %v", err)
|
||||
}
|
||||
|
||||
if cfg.DB.Host != "postgres" || cfg.DB.Port != 5432 || cfg.DB.Name != "botpontos" {
|
||||
t.Errorf("padrões do banco inesperados: %+v", cfg.DB)
|
||||
}
|
||||
if cfg.HTTP.Addr != ":8080" || cfg.Worker.HealthAddr != ":8081" {
|
||||
t.Errorf("endereços inesperados: api=%s worker=%s", cfg.HTTP.Addr, cfg.Worker.HealthAddr)
|
||||
}
|
||||
if cfg.Jobs.ReconciliationInterval != 15*time.Minute {
|
||||
t.Errorf("reconciliação = %s, esperado 15m", cfg.Jobs.ReconciliationInterval)
|
||||
}
|
||||
if cfg.Jobs.DispatchInterval != 2*time.Minute {
|
||||
t.Errorf("despacho = %s, esperado 2m", cfg.Jobs.DispatchInterval)
|
||||
}
|
||||
if cfg.Jobs.LateTolerance != 10*time.Minute {
|
||||
t.Errorf("tolerância = %s, esperado 10m", cfg.Jobs.LateTolerance)
|
||||
}
|
||||
if cfg.Jobs.APIFailureThreshold != 3 {
|
||||
t.Errorf("limite de falhas = %d, esperado 3", cfg.Jobs.APIFailureThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pg_app_password")
|
||||
if err := os.WriteFile(path, []byte("segredo-do-arquivo\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := load(env(map[string]string{
|
||||
"BP_DB_USER": "botpontos_app",
|
||||
"BP_DB_PASSWORD_FILE": path,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("erro inesperado: %v", err)
|
||||
}
|
||||
if cfg.DB.Password != "segredo-do-arquivo" {
|
||||
t.Errorf("senha = %q, esperado sem quebra de linha", cfg.DB.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingSecretFile(t *testing.T) {
|
||||
_, err := load(env(map[string]string{
|
||||
"BP_DB_USER": "botpontos_app",
|
||||
"BP_DB_PASSWORD_FILE": filepath.Join(t.TempDir(), "nao-existe"),
|
||||
}))
|
||||
if err == nil || !strings.Contains(err.Error(), "BP_DB_PASSWORD_FILE") {
|
||||
t.Fatalf("esperado erro de arquivo de segredo, veio: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidValues(t *testing.T) {
|
||||
_, err := load(env(map[string]string{
|
||||
"BP_LOG_LEVEL": "verboso",
|
||||
"BP_DISPATCH_INTERVAL": "abc",
|
||||
"BP_DB_PORT": "porta",
|
||||
"BP_DEFAULT_TIMEZONE": "Lugar/Inexistente",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("esperado erro de validação")
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"BP_LOG_LEVEL",
|
||||
"BP_DISPATCH_INTERVAL",
|
||||
"BP_DB_PORT",
|
||||
"BP_DEFAULT_TIMEZONE",
|
||||
"BP_DB_USER",
|
||||
"BP_DB_PASSWORD",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), expected) {
|
||||
t.Errorf("erro deveria citar %s: %v", expected, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSNEscapesPassword(t *testing.T) {
|
||||
db := DB{Host: "postgres", Port: 5432, Name: "botpontos", User: "app", Password: "p@ss/w:rd?#"}
|
||||
|
||||
parsed, err := url.Parse(db.DSN())
|
||||
if err != nil {
|
||||
t.Fatalf("DSN inválido: %v", err)
|
||||
}
|
||||
password, _ := parsed.User.Password()
|
||||
if password != db.Password {
|
||||
t.Errorf("senha = %q, esperado %q", password, db.Password)
|
||||
}
|
||||
if parsed.Query().Get("sslmode") != "disable" {
|
||||
t.Errorf("sslmode inesperado: %s", parsed.RawQuery)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user