package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
)
func TestLoadConfigAcceptsACompleteConfig(t *testing.T) {
cfg, err := LoadConfig(testIni(t, "/var/lib/spec"))
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Repos != "/var/lib/spec" || cfg.Cache != "/var/cache/spec" {
t.Errorf("paths = %q, %q", cfg.Repos, cfg.Cache)
}
if cfg.Origin != "https://spec.srht.bigb.es" {
t.Errorf("origin = %q", cfg.Origin)
}
if cfg.Instance.AgentEmail != "agent@spec.srht.bigb.es" {
t.Errorf("agent email = %q, want it derived from our own origin", cfg.Instance.AgentEmail)
}
if cfg.Instance.OwnerName != "bigbes" {
t.Errorf("owner = %q", cfg.Instance.OwnerName)
}
}
func TestLoadConfigTrimsTheOriginsTrailingSlash(t *testing.T) {
conf := testIni(t, "/var/lib/spec", "origin")
conf["spec.sr.ht"]["origin"] = "https://spec.srht.bigb.es/"
cfg, err := LoadConfig(conf)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Origin != "https://spec.srht.bigb.es" {
t.Errorf("origin = %q, want the trailing slash gone", cfg.Origin)
}
}
// Every missing key must be named in one message: an operator fixes the config
// in one pass instead of discovering each gap on a separate restart.
func TestLoadConfigNamesEveryMissingKeyAtOnce(t *testing.T) {
conf := testIni(t, "/var/lib/spec", "repos", "cache", "owner-email")
_, err := LoadConfig(conf)
if !errors.Is(err, ErrIncompleteConfig) {
t.Fatalf("err = %v, want ErrIncompleteConfig", err)
}
for _, want := range []string{"[spec.sr.ht] repos", "[spec.sr.ht] cache", "[sr.ht] owner-email"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message does not name %q:\n%s", want, err)
}
}
if strings.Contains(err.Error(), "connection-string") {
t.Errorf("message names a key that was present:\n%s", err)
}
}
func TestLoadConfigRejectsUnusableValues(t *testing.T) {
tests := []struct {
name string
section string
key string
value string
want string
}{
{"relative repos", ConfigSection, "repos", "spec", "absolute path"},
{"relative cache", ConfigSection, "cache", "./cache", "absolute path"},
{"origin with no host", ConfigSection, "origin", "spec.srht.bigb.es", "no host"},
{"origin with a bad scheme", ConfigSection, "origin", "ftp://spec.srht.bigb.es", "http or https"},
{"blank owner", "sr.ht", "owner-name", " ", "[sr.ht] owner-name"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
conf := testIni(t, "/var/lib/spec", tc.key)
conf[tc.section][tc.key] = tc.value
_, err := LoadConfig(conf)
if !errors.Is(err, ErrIncompleteConfig) {
t.Fatalf("err = %v, want ErrIncompleteConfig", err)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("message does not mention %q:\n%s", tc.want, err)
}
})
}
}
// A nil handle would make every agent token resolve as unknown, which looks
// exactly like a mass revocation.
func TestNewRefusesANilDatabaseHandle(t *testing.T) {
if _, err := New(testConfig(t, t.TempDir()), nil); err == nil {
t.Fatal("New with a nil handle succeeded")
}
}
func TestNewValidatesTheConfig(t *testing.T) {
cfg := testConfig(t, "relative/path")
if _, err := New(cfg, deadDB(t)); !errors.Is(err, ErrIncompleteConfig) {
t.Fatalf("err = %v, want ErrIncompleteConfig", err)
}
}
// fakeTokens is an agentTokenLookup that answers from a script.
type fakeTokens struct {
row *db.AgentToken
err error
}
func (f fakeTokens) AgentTokenByHash(context.Context, []byte) (*db.AgentToken, error) {
return f.row, f.err
}
// The whole of the adapter is this error contract: db/ says ErrNotFound, authn
// demands ErrUnknownToken, and an unmapped pass-through would turn a bad
// credential into a 503 telling the agent to retry forever.
func TestTokenStoreMapsAMissingRowToUnknownToken(t *testing.T) {
ts := NewTokenStore(fakeTokens{err: db.ErrNotFound})
_, err := ts.LookupAgentToken(context.Background(), []byte("hash"))
if !errors.Is(err, authn.ErrUnknownToken) {
t.Fatalf("err = %v, want authn.ErrUnknownToken", err)
}
if !authn.IsAuthFailure(err) {
t.Error("an unknown token must be a permanent auth failure, not a transient one")
}
}
func TestTokenStoreKeepsOtherFailuresTransient(t *testing.T) {
boom := errors.New("connection refused")
ts := NewTokenStore(fakeTokens{err: boom})
_, err := ts.LookupAgentToken(context.Background(), []byte("hash"))
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want it to wrap the store failure", err)
}
if authn.IsAuthFailure(err) {
t.Error("a store outage must never read as a bad credential")
}
}
// A revoked row is returned rather than refused, so authn can say "revoked"
// instead of "unknown".
func TestTokenStoreReturnsARevokedRow(t *testing.T) {
revoked := fxTime(1)
ts := NewTokenStore(fakeTokens{row: &db.AgentToken{
ID: 7, Name: "cron", Hash: []byte("h"), Created: fxTime(0), Revoked: &revoked,
}})
tok, err := ts.LookupAgentToken(context.Background(), []byte("h"))
if err != nil {
t.Fatalf("LookupAgentToken: %v", err)
}
if !tok.IsRevoked() || tok.ID != 7 || tok.Name != "cron" {
t.Fatalf("token = %+v", tok)
}
}
func TestTokenStoreRefusesANilRowWithNoError(t *testing.T) {
ts := NewTokenStore(fakeTokens{})
if _, err := ts.LookupAgentToken(context.Background(), []byte("h")); err == nil {
t.Fatal("a nil row with no error authenticated")
}
}
func TestServiceExposesItsWiring(t *testing.T) {
svc, root := newService(t)
if svc.ReposRoot() != root {
t.Errorf("ReposRoot = %q, want %q", svc.ReposRoot(), root)
}
if svc.Origin() != "https://spec.srht.bigb.es" {
t.Errorf("Origin = %q", svc.Origin())
}
if svc.Resolver() == nil || svc.Resolver().Owner() != "bigbes" {
t.Errorf("resolver = %v", svc.Resolver())
}
if svc.Store() == nil || svc.TokenStore() == nil {
t.Error("store or token store is nil")
}
if svc.grace != DefaultReconcileGrace || svc.now == nil {
t.Errorf("reconciler defaults not wired: grace=%v", svc.grace)
}
var _ time.Duration = DefaultReconcileInterval
}