internal/secretbox decodes the standard-base64 key from MEALQUEST_SECRET_KEY_FILE, requires exactly 32 bytes, and seals secrets as nonce ‖ ciphertext with a fresh random 12-byte nonce per call. Errors are sentinels that never carry plaintext, ciphertext or key material. The server builds the box before opening the database, so a non-base64 or wrong-length key stops boot with an error naming the variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
5.7 KiB
Go
196 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
)
|
|
|
|
// useBootMigrations swaps bootMigrations for the test and restores it after.
|
|
// Tests that call it must not run in parallel.
|
|
func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
|
t.Helper()
|
|
saved := bootMigrations
|
|
bootMigrations = m
|
|
t.Cleanup(func() { bootMigrations = saved })
|
|
}
|
|
|
|
// loadConfig loads a configuration through config.Load whose secret key file
|
|
// holds keyFile. Tests that call it must not run in parallel.
|
|
func loadConfig(t *testing.T, keyFile string) config.Config {
|
|
t.Helper()
|
|
keyPath := filepath.Join(t.TempDir(), "secret_key")
|
|
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
|
|
t.Setenv(config.EnvPublicURL, "http://localhost")
|
|
t.Setenv(config.EnvDataDir, t.TempDir())
|
|
t.Setenv(config.EnvShutdownTimeout, "1s")
|
|
t.Setenv(config.EnvSecretKeyFile, keyPath)
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// validKeyFile returns secret key file contents as written by the README:
|
|
// base64 of 32 random bytes followed by a newline.
|
|
func validKeyFile(t *testing.T) string {
|
|
t.Helper()
|
|
key := make([]byte, secretbox.KeySize)
|
|
if _, err := rand.Read(key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(key) + "\n"
|
|
}
|
|
|
|
var brokenMigrations = fstest.MapFS{
|
|
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
|
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
|
}
|
|
|
|
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
|
|
useBootMigrations(t, brokenMigrations)
|
|
|
|
db, err := openStore(context.Background(), t.TempDir())
|
|
if err == nil {
|
|
db.Close()
|
|
t.Fatal("openStore succeeded with a broken migration")
|
|
}
|
|
if db != nil {
|
|
t.Error("openStore returned a store alongside the error")
|
|
}
|
|
if !strings.Contains(err.Error(), "0001_broken.sql") {
|
|
t.Errorf("error does not name the broken migration: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, err := openStore(ctx, t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := db.Close(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
var n int
|
|
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files, err := fs.Glob(migrations.FS, "*.sql")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != len(files) {
|
|
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
|
|
}
|
|
}
|
|
|
|
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
|
useBootMigrations(t, brokenMigrations)
|
|
|
|
var logs bytes.Buffer
|
|
cfg := loadConfig(t, validKeyFile(t))
|
|
errc := make(chan error, 1)
|
|
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
|
|
|
select {
|
|
case err := <-errc:
|
|
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
|
|
t.Fatalf("run error = %v, want the broken migration's error", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("run kept going after a migration failure")
|
|
}
|
|
if strings.Contains(logs.String(), `"listening"`) {
|
|
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
|
}
|
|
}
|
|
|
|
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
|
|
cfg := loadConfig(t, validKeyFile(t))
|
|
box, err := openSecretBox(cfg.SecretKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, err := box.Encrypt([]byte("x"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := box.Decrypt(token); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
|
|
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
|
|
tests := []struct {
|
|
name string
|
|
keyFile string
|
|
want error
|
|
}{
|
|
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
|
|
{"wrong length", short + "\n", secretbox.ErrKeyLength},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := loadConfig(t, tt.keyFile)
|
|
var logs bytes.Buffer
|
|
errc := make(chan error, 1)
|
|
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
|
|
|
var err error
|
|
select {
|
|
case err = <-errc:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("run kept going with a bad secret key")
|
|
}
|
|
if !errors.Is(err, tt.want) {
|
|
t.Fatalf("run error = %v, want %v", err, tt.want)
|
|
}
|
|
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
|
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
|
|
}
|
|
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
|
|
t.Errorf("error leaks the key: %v", err)
|
|
}
|
|
if strings.Contains(logs.String(), `"listening"`) {
|
|
t.Errorf("run started listening despite the bad key: %s", logs.String())
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
|
|
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
|
loadConfig(t, validKeyFile(t)) // sets every other variable
|
|
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
|
|
_, err := config.Load()
|
|
var cfgErr *config.Error
|
|
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
|
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
|
|
}
|
|
}
|