You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
593 lines
16 KiB
593 lines
16 KiB
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) |
|
} |
|
}
|
|
|