check / check (push) Successful in 1m1s
The bot now serves an HTTP API on PORT (default 8080) beside the chat client. Every request needs the credential read at startup from the file named by API_TOKEN_FILE, sent as a bearer token; without one, every request is refused. Responses carry the security headers, bodies are capped at 64 KiB and each request's work at 10 seconds. GET /api/v1/chats lists the bot's contacts from the chat client's /_contacts command, ordered by id, and marks the contacts who deleted their chat with the bot, which the chat client keeps listing. bot.Run starts the API after set-up and stops it within 5 seconds; a listener failure ends the bot as a chat client failure does. Model: opus-5-5
187 lines
4.6 KiB
Go
187 lines
4.6 KiB
Go
package config_test
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/viper"
|
|
"sneak.berlin/go/simplexcalc/internal/config"
|
|
)
|
|
|
|
// env builds a viper instance holding exactly the given keys, so a test
|
|
// describes one environment without touching the process's.
|
|
func env(kv map[string]string) *viper.Viper {
|
|
v := viper.New()
|
|
for k, val := range kv {
|
|
v.Set(k, val)
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// TestAbsentValuesTakeDefaults pins the other half of the iron rule: a
|
|
// value that is not set does get the default. Without this, a bug that
|
|
// rejected everything would pass every test below.
|
|
func TestAbsentValuesTakeDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, kv := range map[string]map[string]string{
|
|
"unset": nil,
|
|
"whitespace only": {
|
|
config.EnvDataDir: " ", config.EnvDebug: " ",
|
|
config.EnvPort: " ", config.EnvAPITokenFile: "\t",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, err := config.Load(env(kv))
|
|
if err != nil {
|
|
t.Fatalf("absent values must be valid, got: %v", err)
|
|
}
|
|
|
|
if c.DataDir != config.DefaultDataDir {
|
|
t.Errorf("DataDir = %q, want %q", c.DataDir, config.DefaultDataDir)
|
|
}
|
|
|
|
if c.Debug {
|
|
t.Error("Debug must default off")
|
|
}
|
|
|
|
if c.Port != config.DefaultPort {
|
|
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
|
|
}
|
|
|
|
if c.APIToken != "" {
|
|
t.Error("APIToken must default to none")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetButUnparseableAborts is the central contract of this package:
|
|
// a value an operator plausibly types, and that does not parse, fails
|
|
// startup rather than being replaced by the default.
|
|
func TestSetButUnparseableAborts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, raw := range []string{"yes", "on", "enabled", "2"} {
|
|
t.Run(raw, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, err := config.Load(env(map[string]string{config.EnvDebug: raw}))
|
|
if err == nil {
|
|
t.Fatalf("wanted a startup failure, got a Config: %+v", c)
|
|
}
|
|
|
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
|
t.Errorf("error does not wrap ErrInvalidConfig: %v", err)
|
|
}
|
|
|
|
if c != nil {
|
|
t.Error("a failed load must return no Config at all")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidValuesAreUsed proves the parsers accept what they document.
|
|
func TestValidValuesAreUsed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c, err := config.Load(env(map[string]string{
|
|
config.EnvDataDir: "/var/lib/example",
|
|
config.EnvDebug: "true",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("valid environment was rejected: %v", err)
|
|
}
|
|
|
|
if c.DataDir != "/var/lib/example" {
|
|
t.Errorf("DataDir = %q, want /var/lib/example", c.DataDir)
|
|
}
|
|
|
|
if !c.Debug {
|
|
t.Error("Debug = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestPort: PORT is a whole number from 1 to 65535, and anything else
|
|
// aborts.
|
|
func TestPort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for raw, want := range map[string]int{"1": 1, "8081": 8081, "65535": 65535} {
|
|
c, err := config.Load(env(map[string]string{config.EnvPort: raw}))
|
|
if err != nil {
|
|
t.Errorf("PORT=%q was rejected: %v", raw, err)
|
|
|
|
continue
|
|
}
|
|
|
|
if c.Port != want {
|
|
t.Errorf("PORT=%q: Port = %d, want %d", raw, c.Port, want)
|
|
}
|
|
}
|
|
|
|
for _, raw := range []string{"0", "65536", "-1", "80.5", "8080x", "http"} {
|
|
_, err := config.Load(env(map[string]string{config.EnvPort: raw}))
|
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
|
t.Errorf("PORT=%q: error = %v, want ErrInvalidConfig", raw, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAPITokenFile: the credential is the file's content without the
|
|
// whitespace around it. A file that cannot be read, or holds too short
|
|
// a credential, aborts, and the error never shows what the file holds.
|
|
func TestAPITokenFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
token := strings.Repeat("k", config.MinAPITokenLength)
|
|
short := strings.Repeat("s", config.MinAPITokenLength-1)
|
|
|
|
for name, content := range map[string]string{
|
|
"good": " " + token + "\n",
|
|
"short": "\n" + short + " \n",
|
|
} {
|
|
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
load := func(name string) (*config.Config, error) {
|
|
return config.Load(env(map[string]string{
|
|
config.EnvAPITokenFile: filepath.Join(dir, name),
|
|
}))
|
|
}
|
|
|
|
c, err := load("good")
|
|
if err != nil {
|
|
t.Fatalf("a good file was rejected: %v", err)
|
|
}
|
|
|
|
if c.APIToken != token {
|
|
t.Errorf("APIToken = %q, want %q", c.APIToken, token)
|
|
}
|
|
|
|
_, err = load("missing")
|
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
|
t.Errorf("a missing file: error = %v, want ErrInvalidConfig", err)
|
|
}
|
|
|
|
_, err = load("short")
|
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
|
t.Fatalf("a short credential: error = %v, want ErrInvalidConfig", err)
|
|
}
|
|
|
|
if strings.Contains(err.Error(), short) {
|
|
t.Errorf("the error shows the file's content: %v", err)
|
|
}
|
|
}
|