package authn
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"github.com/dolthub/dolt/go/libraries/doltcore/creds"
"github.com/fernet/fernet-go"
"github.com/vaughan0/go-ini"
jose "gopkg.in/go-jose/go-jose.v2"
"gopkg.in/go-jose/go-jose.v2/jwt"
)
// TestMain synthesizes an in-memory instance config (random fernet network key +
// random ed25519 webhooks seed) and runs crypto.InitCrypto once, so that cookie
// encryption (crypto.Encrypt / DecryptWithoutExpiration) and bearer-token HMAC
// (auth.BearerToken.Encode / auth.DecodeBearerToken) share a keyset across the
// whole package's tests. No network, no Postgres.
func TestMain(m *testing.M) {
var fk fernet.Key
if err := fk.Generate(); err != nil {
panic(err)
}
seed := make([]byte, ed25519.SeedSize)
// Deterministic non-zero seed is fine; these keys never leave the test.
for i := range seed {
seed[i] = byte(i + 1)
}
conf := ini.File{
"sr.ht": ini.Section{"network-key": fk.Encode()},
"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
}
crypto.InitCrypto(conf)
os.Exit(m.Run())
}
// testCtx returns a context carrying the config/service so that
// auth.DecodeGrants (which reads config.ServiceName) works in tests.
func testCtx() context.Context {
return config.Context(context.Background(), ini.File{}, "dolt.sr.ht")
}
// stubBackend is an in-memory MetaBackend: LookupUser fills from a fixed table,
// IsRevoked consults a set of revoked hashes. Both can be told to fail
// (transient error) to exercise the temporary-error path.
type stubBackend struct {
users map[string]auth.AuthContext // keyed by lowercased username (no "~")
revoked map[[64]byte]bool
lookupErr error
revokeErr error
}
func (s *stubBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error {
if s.lookupErr != nil {
return s.lookupErr
}
u, ok := s.users[normalize(username)]
if !ok {
return errUnknownUser
}
*out = u
return nil
}
func (s *stubBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) {
if s.revokeErr != nil {
return false, s.revokeErr
}
return s.revoked[hash], nil
}
func normalize(username string) string {
if len(username) > 0 && username[0] == '~' {
username = username[1:]
}
return toLower(username)
}
func toLower(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + ('a' - 'A')
}
}
return string(b)
}
var errUnknownUser = errTest("unknown user")
var errBackendDown = errTest("backend down")
type errTest string
func (e errTest) Error() string { return string(e) }
// withStubBackend installs a stub MetaBackend for the duration of a test and
// resets the token cache and clock, restoring everything afterwards.
func withStubBackend(t *testing.T, s MetaBackend) {
t.Helper()
prev := meta
meta = s
resetTokenCache()
prevNow := nowFn
t.Cleanup(func() {
meta = prev
nowFn = prevNow
resetTokenCache()
})
}
func resetTokenCache() {
tokenCacheMu.Lock()
tokenCache = map[[64]byte]cacheEntry{}
tokenCacheMu.Unlock()
}
func sampleUser(id int, username, userType string) auth.AuthContext {
notice := "suspended for testing"
ac := auth.AuthContext{
UserID: id,
Username: username,
Email: username + "@example.com",
UserType: userType,
}
if userType == auth.USER_TYPE_SUSPENDED {
ac.SuspensionNotice = ¬ice
}
return ac
}
// forgeCookie fernet-encrypts a unified-login cookie payload for username.
func forgeCookie(t *testing.T, username string) string {
t.Helper()
payload, err := json.Marshal(auth.AuthCookie{Name: username})
if err != nil {
t.Fatal(err)
}
return string(crypto.Encrypt(payload))
}
// forgePAT builds a valid meta personal access token for username with the
// given grants and expiry, HMAC-signed with the test keyset.
func forgePAT(username, grants string, expires time.Time) string {
bt := auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(expires),
Grants: grants,
Username: username,
}
return bt.Encode()
}
// mintDoltJWT builds an EdDSA JWS exactly as dolt's creds.RPCCreds does: kid +
// dolt_token_version headers, and aud/iss/sub/exp claims.
func mintDoltJWT(t *testing.T, priv ed25519.PrivateKey, kid, aud, iss, sub string, expiry time.Time) string {
t.Helper()
signingKey := jose.SigningKey{Algorithm: jose.EdDSA, Key: priv}
opts := &jose.SignerOptions{ExtraHeaders: map[jose.HeaderKey]interface{}{
jose.HeaderKey(creds.JWTKIDHeader): kid,
jose.HeaderKey(creds.DoltTokenVersionHeader): "2023.01",
}}
signer, err := jose.NewSigner(signingKey, opts)
if err != nil {
t.Fatal(err)
}
raw, err := jwt.Signed(signer).Claims(jwt.Claims{
Audience: jwt.Audience{aud},
Issuer: iss,
Subject: sub,
Expiry: jwt.NewNumericDate(expiry),
}).CompactSerialize()
if err != nil {
t.Fatal(err)
}
return raw
}