Files
next.orly.dev/app/nip43_e2e_test.go
mleku c1bd05fb04
Some checks failed
Go / build-and-release (push) Has been cancelled
Adjust ACL behavior for "none" mode and make query cache optional
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.
2025-12-05 11:25:34 +00:00

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)
}
}