check / check (push) Successful in 1m13s
The bot now serves an HTTP API on `PORT` (default 8080) beside the chat client, whose WebSocket stays on 127.0.0.1 inside the container. Every request needs `Authorization: Bearer` with the credential from the file named by `API_TOKEN_FILE`, compared in constant time; with no credential configured every request is refused, `OPTIONS *` included. `GET /api/v1/chats` lists the bot's chats. Responses carry the security headers from the repository policies; bodies, requests and the server are time- and size-bounded. The chat client stops only after the API has finished its requests. Disclosures: `contact_deleted` is an extra field; 404 and 405 answer in JSON; requests net/http cannot parse are refused by net/http without the security headers; three gosec findings are suppressed as false positives. 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)
|
|
}
|
|
}
|