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 }