Some checks failed
Go / build-and-release (push) Has been cancelled
This commit allows skipping authentication, permission checks, and certain filters (e.g., deletions, expirations) when the ACL mode is set to "none" (open relay mode). It also introduces a configuration option to disable query caching to reduce memory usage. These changes improve operational flexibility for open relay setups and resource-constrained environments.
594 lines
16 KiB
Go
594 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"next.orly.dev/app/config"
|
|
"next.orly.dev/pkg/acl"
|
|
"git.mleku.dev/mleku/nostr/crypto/keys"
|
|
"next.orly.dev/pkg/database"
|
|
"git.mleku.dev/mleku/nostr/encoders/event"
|
|
"git.mleku.dev/mleku/nostr/encoders/hex"
|
|
"git.mleku.dev/mleku/nostr/encoders/tag"
|
|
"next.orly.dev/pkg/protocol/nip43"
|
|
"next.orly.dev/pkg/protocol/publish"
|
|
"git.mleku.dev/mleku/nostr/relayinfo"
|
|
)
|
|
|
|
// newTestListener creates a properly initialized Listener for testing
|
|
func newTestListener(server *Server, ctx context.Context) *Listener {
|
|
listener := &Listener{
|
|
Server: server,
|
|
ctx: ctx,
|
|
writeChan: make(chan publish.WriteRequest, 100),
|
|
writeDone: make(chan struct{}),
|
|
messageQueue: make(chan messageRequest, 100),
|
|
processingDone: make(chan struct{}),
|
|
subscriptions: make(map[string]context.CancelFunc),
|
|
}
|
|
|
|
// Start write worker and message processor
|
|
go listener.writeWorker()
|
|
go listener.messageProcessor()
|
|
|
|
return listener
|
|
}
|
|
|
|
// closeTestListener properly closes a test listener
|
|
func closeTestListener(listener *Listener) {
|
|
close(listener.writeChan)
|
|
<-listener.writeDone
|
|
close(listener.messageQueue)
|
|
<-listener.processingDone
|
|
}
|
|
|
|
// setupE2ETest creates a full test server for end-to-end testing
|
|
func setupE2ETest(t *testing.T) (*Server, *httptest.Server, func()) {
|
|
tempDir, err := os.MkdirTemp("", "nip43_e2e_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
db, err := database.New(ctx, cancel, tempDir, "info")
|
|
if err != nil {
|
|
os.RemoveAll(tempDir)
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
|
|
cfg := &config.C{
|
|
AppName: "TestRelay",
|
|
NIP43Enabled: true,
|
|
NIP43PublishEvents: true,
|
|
NIP43PublishMemberList: true,
|
|
NIP43InviteExpiry: 24 * time.Hour,
|
|
RelayURL: "wss://test.relay",
|
|
Listen: "localhost",
|
|
Port: 3334,
|
|
ACLMode: "none",
|
|
AuthRequired: false,
|
|
}
|
|
|
|
// Generate admin keys
|
|
adminSecret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate admin secret: %v", err)
|
|
}
|
|
adminSigner, err := p8k.New()
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin signer: %v", err)
|
|
}
|
|
if err = adminSigner.InitSec(adminSecret); err != nil {
|
|
t.Fatalf("failed to initialize admin signer: %v", err)
|
|
}
|
|
adminPubkey := adminSigner.Pub()
|
|
|
|
// Add admin to config for ACL
|
|
cfg.Admins = []string{hex.Enc(adminPubkey)}
|
|
|
|
server := &Server{
|
|
Ctx: ctx,
|
|
Config: cfg,
|
|
DB: db,
|
|
publishers: publish.New(NewPublisher(ctx)),
|
|
Admins: [][]byte{adminPubkey},
|
|
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
|
|
cfg: cfg,
|
|
db: db,
|
|
}
|
|
|
|
// Configure ACL registry
|
|
acl.Registry.SetMode(cfg.ACLMode)
|
|
if err = acl.Registry.Configure(cfg, db, ctx); err != nil {
|
|
db.Close()
|
|
os.RemoveAll(tempDir)
|
|
t.Fatalf("failed to configure ACL: %v", err)
|
|
}
|
|
|
|
server.mux = http.NewServeMux()
|
|
|
|
// Set up HTTP handlers
|
|
server.mux.HandleFunc(
|
|
"/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Accept") == "application/nostr+json" {
|
|
server.HandleRelayInfo(w, r)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
},
|
|
)
|
|
|
|
httpServer := httptest.NewServer(server.mux)
|
|
|
|
cleanup := func() {
|
|
httpServer.Close()
|
|
db.Close()
|
|
os.RemoveAll(tempDir)
|
|
}
|
|
|
|
return server, httpServer, cleanup
|
|
}
|
|
|
|
// TestE2E_RelayInfoIncludesNIP43 tests that NIP-43 is advertised in relay info
|
|
func TestE2E_RelayInfoIncludesNIP43(t *testing.T) {
|
|
server, httpServer, cleanup := setupE2ETest(t)
|
|
defer cleanup()
|
|
|
|
// Make request to relay info endpoint
|
|
req, err := http.NewRequest("GET", httpServer.URL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
req.Header.Set("Accept", "application/nostr+json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to make request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Parse relay info
|
|
var info relayinfo.T
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
t.Fatalf("failed to decode relay info: %v", err)
|
|
}
|
|
|
|
// Verify NIP-43 is in supported NIPs
|
|
hasNIP43 := false
|
|
for _, nip := range info.Nips {
|
|
if nip == 43 {
|
|
hasNIP43 = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasNIP43 {
|
|
t.Error("NIP-43 not advertised in supported_nips")
|
|
}
|
|
|
|
// Verify server name
|
|
if info.Name != server.Config.AppName {
|
|
t.Errorf(
|
|
"wrong relay name: got %s, want %s", info.Name,
|
|
server.Config.AppName,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestE2E_CompleteJoinFlow tests the complete user join flow
|
|
func TestE2E_CompleteJoinFlow(t *testing.T) {
|
|
server, _, cleanup := setupE2ETest(t)
|
|
defer cleanup()
|
|
|
|
// Step 1: Admin requests invite code
|
|
adminPubkey := server.Admins[0]
|
|
inviteEvent, err := server.HandleNIP43InviteRequest(adminPubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate invite: %v", err)
|
|
}
|
|
|
|
// Extract invite code
|
|
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
|
|
if claimTag == nil || claimTag.Len() < 2 {
|
|
t.Fatal("invite event missing claim tag")
|
|
}
|
|
inviteCode := string(claimTag.T[1])
|
|
|
|
// Step 2: User creates join request
|
|
userSecret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate user secret: %v", err)
|
|
}
|
|
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to get user pubkey: %v", err)
|
|
}
|
|
signer, err := keys.SecretBytesToSigner(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to create signer: %v", err)
|
|
}
|
|
|
|
joinEv := event.New()
|
|
joinEv.Kind = nip43.KindJoinRequest
|
|
copy(joinEv.Pubkey, userPubkey)
|
|
joinEv.Tags = tag.NewS()
|
|
joinEv.Tags.Append(tag.NewFromAny("-"))
|
|
joinEv.Tags.Append(tag.NewFromAny("claim", inviteCode))
|
|
joinEv.CreatedAt = time.Now().Unix()
|
|
joinEv.Content = []byte("")
|
|
if err = joinEv.Sign(signer); err != nil {
|
|
t.Fatalf("failed to sign join event: %v", err)
|
|
}
|
|
|
|
// Step 3: Process join request
|
|
listener := newTestListener(server, server.Ctx)
|
|
defer closeTestListener(listener)
|
|
err = listener.HandleNIP43JoinRequest(joinEv)
|
|
if err != nil {
|
|
t.Fatalf("failed to handle join request: %v", err)
|
|
}
|
|
|
|
// Step 4: Verify membership
|
|
isMember, err := server.DB.IsNIP43Member(userPubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to check membership: %v", err)
|
|
}
|
|
if !isMember {
|
|
t.Error("user was not added as member")
|
|
}
|
|
|
|
membership, err := server.DB.GetNIP43Membership(userPubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to get membership: %v", err)
|
|
}
|
|
if membership.InviteCode != inviteCode {
|
|
t.Errorf(
|
|
"wrong invite code: got %s, want %s", membership.InviteCode,
|
|
inviteCode,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestE2E_InviteCodeReuse tests that invite codes can only be used once
|
|
func TestE2E_InviteCodeReuse(t *testing.T) {
|
|
server, _, cleanup := setupE2ETest(t)
|
|
defer cleanup()
|
|
|
|
// Generate invite code
|
|
code, err := server.InviteManager.GenerateCode()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate invite code: %v", err)
|
|
}
|
|
|
|
listener := newTestListener(server, server.Ctx)
|
|
defer closeTestListener(listener)
|
|
|
|
// First user uses the code
|
|
user1Secret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate user1 secret: %v", err)
|
|
}
|
|
user1Pubkey, err := keys.SecretBytesToPubKeyBytes(user1Secret)
|
|
if err != nil {
|
|
t.Fatalf("failed to get user1 pubkey: %v", err)
|
|
}
|
|
signer1, err := keys.SecretBytesToSigner(user1Secret)
|
|
if err != nil {
|
|
t.Fatalf("failed to create signer1: %v", err)
|
|
}
|
|
|
|
joinEv1 := event.New()
|
|
joinEv1.Kind = nip43.KindJoinRequest
|
|
copy(joinEv1.Pubkey, user1Pubkey)
|
|
joinEv1.Tags = tag.NewS()
|
|
joinEv1.Tags.Append(tag.NewFromAny("-"))
|
|
joinEv1.Tags.Append(tag.NewFromAny("claim", code))
|
|
joinEv1.CreatedAt = time.Now().Unix()
|
|
joinEv1.Content = []byte("")
|
|
if err = joinEv1.Sign(signer1); err != nil {
|
|
t.Fatalf("failed to sign join event 1: %v", err)
|
|
}
|
|
|
|
err = listener.HandleNIP43JoinRequest(joinEv1)
|
|
if err != nil {
|
|
t.Fatalf("failed to handle join request 1: %v", err)
|
|
}
|
|
|
|
// Verify first user is member
|
|
isMember, err := server.DB.IsNIP43Member(user1Pubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to check user1 membership: %v", err)
|
|
}
|
|
if !isMember {
|
|
t.Error("user1 was not added")
|
|
}
|
|
|
|
// Second user tries to use same code
|
|
user2Secret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate user2 secret: %v", err)
|
|
}
|
|
user2Pubkey, err := keys.SecretBytesToPubKeyBytes(user2Secret)
|
|
if err != nil {
|
|
t.Fatalf("failed to get user2 pubkey: %v", err)
|
|
}
|
|
signer2, err := keys.SecretBytesToSigner(user2Secret)
|
|
if err != nil {
|
|
t.Fatalf("failed to create signer2: %v", err)
|
|
}
|
|
|
|
joinEv2 := event.New()
|
|
joinEv2.Kind = nip43.KindJoinRequest
|
|
copy(joinEv2.Pubkey, user2Pubkey)
|
|
joinEv2.Tags = tag.NewS()
|
|
joinEv2.Tags.Append(tag.NewFromAny("-"))
|
|
joinEv2.Tags.Append(tag.NewFromAny("claim", code))
|
|
joinEv2.CreatedAt = time.Now().Unix()
|
|
joinEv2.Content = []byte("")
|
|
if err = joinEv2.Sign(signer2); err != nil {
|
|
t.Fatalf("failed to sign join event 2: %v", err)
|
|
}
|
|
|
|
// Should handle without error but not add user
|
|
err = listener.HandleNIP43JoinRequest(joinEv2)
|
|
if err != nil {
|
|
t.Fatalf("handler returned error: %v", err)
|
|
}
|
|
|
|
// Verify second user is NOT member
|
|
isMember, err = server.DB.IsNIP43Member(user2Pubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to check user2 membership: %v", err)
|
|
}
|
|
if isMember {
|
|
t.Error("user2 was incorrectly added with reused code")
|
|
}
|
|
}
|
|
|
|
// TestE2E_MembershipListGeneration tests membership list event generation
|
|
func TestE2E_MembershipListGeneration(t *testing.T) {
|
|
server, _, cleanup := setupE2ETest(t)
|
|
defer cleanup()
|
|
|
|
listener := newTestListener(server, server.Ctx)
|
|
defer closeTestListener(listener)
|
|
|
|
// Add multiple members
|
|
memberCount := 5
|
|
members := make([][]byte, memberCount)
|
|
|
|
for i := 0; i < memberCount; i++ {
|
|
userSecret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate user secret %d: %v", i, err)
|
|
}
|
|
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to get user pubkey %d: %v", i, err)
|
|
}
|
|
members[i] = userPubkey
|
|
|
|
// Add directly to database for speed
|
|
err = server.DB.AddNIP43Member(userPubkey, "code")
|
|
if err != nil {
|
|
t.Fatalf("failed to add member %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Generate membership list
|
|
err := listener.publishMembershipList()
|
|
if err != nil {
|
|
t.Fatalf("failed to publish membership list: %v", err)
|
|
}
|
|
|
|
// Note: In a real test, you would verify the event was published
|
|
// through the publishers system. For now, we just verify no error.
|
|
}
|
|
|
|
// TestE2E_ExpiredInviteCode tests that expired codes are rejected
|
|
func TestE2E_ExpiredInviteCode(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "nip43_expired_test_*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
db, err := database.New(ctx, cancel, tempDir, "info")
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
cfg := &config.C{
|
|
NIP43Enabled: true,
|
|
NIP43InviteExpiry: 1 * time.Millisecond, // Very short expiry
|
|
}
|
|
|
|
server := &Server{
|
|
Ctx: ctx,
|
|
Config: cfg,
|
|
DB: db,
|
|
publishers: publish.New(NewPublisher(ctx)),
|
|
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
|
|
cfg: cfg,
|
|
db: db,
|
|
}
|
|
|
|
listener := newTestListener(server, ctx)
|
|
defer closeTestListener(listener)
|
|
|
|
// Generate invite code
|
|
code, err := server.InviteManager.GenerateCode()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate invite code: %v", err)
|
|
}
|
|
|
|
// Wait for expiry
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Try to use expired code
|
|
userSecret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate user secret: %v", err)
|
|
}
|
|
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to get user pubkey: %v", err)
|
|
}
|
|
signer, err := keys.SecretBytesToSigner(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to create signer: %v", err)
|
|
}
|
|
|
|
joinEv := event.New()
|
|
joinEv.Kind = nip43.KindJoinRequest
|
|
copy(joinEv.Pubkey, userPubkey)
|
|
joinEv.Tags = tag.NewS()
|
|
joinEv.Tags.Append(tag.NewFromAny("-"))
|
|
joinEv.Tags.Append(tag.NewFromAny("claim", code))
|
|
joinEv.CreatedAt = time.Now().Unix()
|
|
joinEv.Content = []byte("")
|
|
if err = joinEv.Sign(signer); err != nil {
|
|
t.Fatalf("failed to sign event: %v", err)
|
|
}
|
|
|
|
err = listener.HandleNIP43JoinRequest(joinEv)
|
|
if err != nil {
|
|
t.Fatalf("handler returned error: %v", err)
|
|
}
|
|
|
|
// Verify user was NOT added
|
|
isMember, err := db.IsNIP43Member(userPubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to check membership: %v", err)
|
|
}
|
|
if isMember {
|
|
t.Error("user was added with expired code")
|
|
}
|
|
}
|
|
|
|
// TestE2E_InvalidTimestampRejected tests that events with invalid timestamps are rejected
|
|
func TestE2E_InvalidTimestampRejected(t *testing.T) {
|
|
server, _, cleanup := setupE2ETest(t)
|
|
defer cleanup()
|
|
|
|
listener := newTestListener(server, server.Ctx)
|
|
defer closeTestListener(listener)
|
|
|
|
// Generate invite code
|
|
code, err := server.InviteManager.GenerateCode()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate invite code: %v", err)
|
|
}
|
|
|
|
// Create user
|
|
userSecret, err := keys.GenerateSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate user secret: %v", err)
|
|
}
|
|
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to get user pubkey: %v", err)
|
|
}
|
|
signer, err := keys.SecretBytesToSigner(userSecret)
|
|
if err != nil {
|
|
t.Fatalf("failed to create signer: %v", err)
|
|
}
|
|
|
|
// Create join request with timestamp far in the past
|
|
joinEv := event.New()
|
|
joinEv.Kind = nip43.KindJoinRequest
|
|
copy(joinEv.Pubkey, userPubkey)
|
|
joinEv.Tags = tag.NewS()
|
|
joinEv.Tags.Append(tag.NewFromAny("-"))
|
|
joinEv.Tags.Append(tag.NewFromAny("claim", code))
|
|
joinEv.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
|
|
joinEv.Content = []byte("")
|
|
if err = joinEv.Sign(signer); err != nil {
|
|
t.Fatalf("failed to sign event: %v", err)
|
|
}
|
|
|
|
// Should handle without error but not add user
|
|
err = listener.HandleNIP43JoinRequest(joinEv)
|
|
if err != nil {
|
|
t.Fatalf("handler returned error: %v", err)
|
|
}
|
|
|
|
// Verify user was NOT added
|
|
isMember, err := server.DB.IsNIP43Member(userPubkey)
|
|
if err != nil {
|
|
t.Fatalf("failed to check membership: %v", err)
|
|
}
|
|
if isMember {
|
|
t.Error("user was added with invalid timestamp")
|
|
}
|
|
}
|
|
|
|
// BenchmarkJoinRequestProcessing benchmarks join request processing
|
|
func BenchmarkJoinRequestProcessing(b *testing.B) {
|
|
tempDir, err := os.MkdirTemp("", "nip43_bench_*")
|
|
if err != nil {
|
|
b.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
db, err := database.New(ctx, cancel, tempDir, "error")
|
|
if err != nil {
|
|
b.Fatalf("failed to open database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
cfg := &config.C{
|
|
NIP43Enabled: true,
|
|
NIP43InviteExpiry: 24 * time.Hour,
|
|
}
|
|
|
|
server := &Server{
|
|
Ctx: ctx,
|
|
Config: cfg,
|
|
DB: db,
|
|
publishers: publish.New(NewPublisher(ctx)),
|
|
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
|
|
cfg: cfg,
|
|
db: db,
|
|
}
|
|
|
|
listener := newTestListener(server, ctx)
|
|
defer closeTestListener(listener)
|
|
|
|
b.ResetTimer()
|
|
|
|
for i := 0; i < b.N; i++ {
|
|
// Generate unique user and code for each iteration
|
|
userSecret, _ := keys.GenerateSecretKey()
|
|
userPubkey, _ := keys.SecretBytesToPubKeyBytes(userSecret)
|
|
signer, _ := keys.SecretBytesToSigner(userSecret)
|
|
code, _ := server.InviteManager.GenerateCode()
|
|
|
|
joinEv := event.New()
|
|
joinEv.Kind = nip43.KindJoinRequest
|
|
copy(joinEv.Pubkey, userPubkey)
|
|
joinEv.Tags = tag.NewS()
|
|
joinEv.Tags.Append(tag.NewFromAny("-"))
|
|
joinEv.Tags.Append(tag.NewFromAny("claim", code))
|
|
joinEv.CreatedAt = time.Now().Unix()
|
|
joinEv.Content = []byte("")
|
|
joinEv.Sign(signer)
|
|
|
|
listener.HandleNIP43JoinRequest(joinEv)
|
|
}
|
|
}
|