25 changed files with 2958 additions and 203 deletions
@ -0,0 +1,254 @@
@@ -0,0 +1,254 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
"time" |
||||
|
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
"next.orly.dev/pkg/acl" |
||||
"next.orly.dev/pkg/encoders/envelopes/okenvelope" |
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
"next.orly.dev/pkg/protocol/nip43" |
||||
) |
||||
|
||||
// HandleNIP43JoinRequest processes a kind 28934 join request
|
||||
func (l *Listener) HandleNIP43JoinRequest(ev *event.E) error { |
||||
log.I.F("handling NIP-43 join request from %s", hex.Enc(ev.Pubkey)) |
||||
|
||||
// Validate the join request
|
||||
inviteCode, valid, reason := nip43.ValidateJoinRequest(ev) |
||||
if !valid { |
||||
log.W.F("invalid join request: %s", reason) |
||||
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason)) |
||||
} |
||||
|
||||
// Check if user is already a member
|
||||
isMember, err := l.D.IsNIP43Member(ev.Pubkey) |
||||
if chk.E(err) { |
||||
log.E.F("error checking membership: %v", err) |
||||
return l.sendOKResponse(ev.ID, false, "error: internal server error") |
||||
} |
||||
|
||||
if isMember { |
||||
log.I.F("user %s is already a member", hex.Enc(ev.Pubkey)) |
||||
return l.sendOKResponse(ev.ID, true, "duplicate: you are already a member of this relay") |
||||
} |
||||
|
||||
// Validate the invite code
|
||||
validCode, reason := l.Server.InviteManager.ValidateAndConsume(inviteCode, ev.Pubkey) |
||||
|
||||
if !validCode { |
||||
log.W.F("invalid or expired invite code: %s - %s", inviteCode, reason) |
||||
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason)) |
||||
} |
||||
|
||||
// Add the member
|
||||
if err = l.D.AddNIP43Member(ev.Pubkey, inviteCode); chk.E(err) { |
||||
log.E.F("error adding member: %v", err) |
||||
return l.sendOKResponse(ev.ID, false, "error: failed to add member") |
||||
} |
||||
|
||||
log.I.F("successfully added member %s via invite code", hex.Enc(ev.Pubkey)) |
||||
|
||||
// Publish kind 8000 "add member" event if configured
|
||||
if l.Config.NIP43PublishEvents { |
||||
if err = l.publishAddUserEvent(ev.Pubkey); chk.E(err) { |
||||
log.W.F("failed to publish add user event: %v", err) |
||||
} |
||||
} |
||||
|
||||
// Update membership list if configured
|
||||
if l.Config.NIP43PublishMemberList { |
||||
if err = l.publishMembershipList(); chk.E(err) { |
||||
log.W.F("failed to publish membership list: %v", err) |
||||
} |
||||
} |
||||
|
||||
relayURL := l.Config.RelayURL |
||||
if relayURL == "" { |
||||
relayURL = fmt.Sprintf("wss://%s:%d", l.Config.Listen, l.Config.Port) |
||||
} |
||||
|
||||
return l.sendOKResponse(ev.ID, true, fmt.Sprintf("welcome to %s!", relayURL)) |
||||
} |
||||
|
||||
// HandleNIP43LeaveRequest processes a kind 28936 leave request
|
||||
func (l *Listener) HandleNIP43LeaveRequest(ev *event.E) error { |
||||
log.I.F("handling NIP-43 leave request from %s", hex.Enc(ev.Pubkey)) |
||||
|
||||
// Validate the leave request
|
||||
valid, reason := nip43.ValidateLeaveRequest(ev) |
||||
if !valid { |
||||
log.W.F("invalid leave request: %s", reason) |
||||
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("error: %s", reason)) |
||||
} |
||||
|
||||
// Check if user is a member
|
||||
isMember, err := l.D.IsNIP43Member(ev.Pubkey) |
||||
if chk.E(err) { |
||||
log.E.F("error checking membership: %v", err) |
||||
return l.sendOKResponse(ev.ID, false, "error: internal server error") |
||||
} |
||||
|
||||
if !isMember { |
||||
log.I.F("user %s is not a member", hex.Enc(ev.Pubkey)) |
||||
return l.sendOKResponse(ev.ID, true, "you are not a member of this relay") |
||||
} |
||||
|
||||
// Remove the member
|
||||
if err = l.D.RemoveNIP43Member(ev.Pubkey); chk.E(err) { |
||||
log.E.F("error removing member: %v", err) |
||||
return l.sendOKResponse(ev.ID, false, "error: failed to remove member") |
||||
} |
||||
|
||||
log.I.F("successfully removed member %s", hex.Enc(ev.Pubkey)) |
||||
|
||||
// Publish kind 8001 "remove member" event if configured
|
||||
if l.Config.NIP43PublishEvents { |
||||
if err = l.publishRemoveUserEvent(ev.Pubkey); chk.E(err) { |
||||
log.W.F("failed to publish remove user event: %v", err) |
||||
} |
||||
} |
||||
|
||||
// Update membership list if configured
|
||||
if l.Config.NIP43PublishMemberList { |
||||
if err = l.publishMembershipList(); chk.E(err) { |
||||
log.W.F("failed to publish membership list: %v", err) |
||||
} |
||||
} |
||||
|
||||
return l.sendOKResponse(ev.ID, true, "you have been removed from this relay") |
||||
} |
||||
|
||||
// HandleNIP43InviteRequest processes a kind 28935 invite request (REQ subscription)
|
||||
func (s *Server) HandleNIP43InviteRequest(pubkey []byte) (*event.E, error) { |
||||
log.I.F("generating NIP-43 invite for pubkey %s", hex.Enc(pubkey)) |
||||
|
||||
// Check if requester has permission to request invites
|
||||
// This could be based on ACL, admins, etc.
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, "") |
||||
if accessLevel != "admin" && accessLevel != "owner" { |
||||
log.W.F("unauthorized invite request from %s (level: %s)", hex.Enc(pubkey), accessLevel) |
||||
return nil, fmt.Errorf("unauthorized: only admins can request invites") |
||||
} |
||||
|
||||
// Generate a new invite code
|
||||
code, err := s.InviteManager.GenerateCode() |
||||
if chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
// Get relay identity
|
||||
relaySecret, err := s.db.GetOrCreateRelayIdentitySecret() |
||||
if chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
// Build the invite event
|
||||
inviteEvent, err := nip43.BuildInviteEvent(relaySecret, code) |
||||
if chk.E(err) { |
||||
return nil, err |
||||
} |
||||
|
||||
log.I.F("generated invite code for %s", hex.Enc(pubkey)) |
||||
return inviteEvent, nil |
||||
} |
||||
|
||||
// publishAddUserEvent publishes a kind 8000 add user event
|
||||
func (l *Listener) publishAddUserEvent(userPubkey []byte) error { |
||||
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret() |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
ev, err := nip43.BuildAddUserEvent(relaySecret, userPubkey) |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// Save to database
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
||||
defer cancel() |
||||
if _, err = l.SaveEvent(ctx, ev); chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// Publish to subscribers
|
||||
l.publishers.Deliver(ev) |
||||
|
||||
log.I.F("published kind 8000 add user event for %s", hex.Enc(userPubkey)) |
||||
return nil |
||||
} |
||||
|
||||
// publishRemoveUserEvent publishes a kind 8001 remove user event
|
||||
func (l *Listener) publishRemoveUserEvent(userPubkey []byte) error { |
||||
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret() |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
ev, err := nip43.BuildRemoveUserEvent(relaySecret, userPubkey) |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// Save to database
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
||||
defer cancel() |
||||
if _, err = l.SaveEvent(ctx, ev); chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// Publish to subscribers
|
||||
l.publishers.Deliver(ev) |
||||
|
||||
log.I.F("published kind 8001 remove user event for %s", hex.Enc(userPubkey)) |
||||
return nil |
||||
} |
||||
|
||||
// publishMembershipList publishes a kind 13534 membership list event
|
||||
func (l *Listener) publishMembershipList() error { |
||||
// Get all members
|
||||
members, err := l.D.GetAllNIP43Members() |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret() |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
ev, err := nip43.BuildMemberListEvent(relaySecret, members) |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// Save to database
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
||||
defer cancel() |
||||
if _, err = l.SaveEvent(ctx, ev); chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// Publish to subscribers
|
||||
l.publishers.Deliver(ev) |
||||
|
||||
log.I.F("published kind 13534 membership list event with %d members", len(members)) |
||||
return nil |
||||
} |
||||
|
||||
// sendOKResponse sends an OK envelope response
|
||||
func (l *Listener) sendOKResponse(eventID []byte, accepted bool, message string) error { |
||||
// Ensure message doesn't have "restricted: " prefix if already present
|
||||
if accepted && strings.HasPrefix(message, "restricted: ") { |
||||
message = strings.TrimPrefix(message, "restricted: ") |
||||
} |
||||
|
||||
env := okenvelope.NewFrom(eventID, accepted, []byte(message)) |
||||
return env.Write(l) |
||||
} |
||||
@ -0,0 +1,570 @@
@@ -0,0 +1,570 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
"testing" |
||||
"time" |
||||
|
||||
"next.orly.dev/app/config" |
||||
"next.orly.dev/pkg/crypto/keys" |
||||
"next.orly.dev/pkg/database" |
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/tag" |
||||
"next.orly.dev/pkg/interfaces/signer/p8k" |
||||
"next.orly.dev/pkg/protocol/nip43" |
||||
"next.orly.dev/pkg/protocol/publish" |
||||
) |
||||
|
||||
// setupTestListener creates a test listener with NIP-43 enabled
|
||||
func setupTestListener(t *testing.T) (*Listener, *database.D, func()) { |
||||
tempDir, err := os.MkdirTemp("", "nip43_handler_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{ |
||||
NIP43Enabled: true, |
||||
NIP43PublishEvents: true, |
||||
NIP43PublishMemberList: true, |
||||
NIP43InviteExpiry: 24 * time.Hour, |
||||
RelayURL: "wss://test.relay", |
||||
Listen: "localhost", |
||||
Port: 3334, |
||||
} |
||||
|
||||
server := &Server{ |
||||
Ctx: ctx, |
||||
Config: cfg, |
||||
D: db, |
||||
publishers: publish.New(NewPublisher(ctx)), |
||||
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry), |
||||
cfg: cfg, |
||||
db: db, |
||||
} |
||||
|
||||
listener := &Listener{ |
||||
Server: server, |
||||
ctx: ctx, |
||||
} |
||||
|
||||
cleanup := func() { |
||||
db.Close() |
||||
os.RemoveAll(tempDir) |
||||
} |
||||
|
||||
return listener, db, cleanup |
||||
} |
||||
|
||||
// TestHandleNIP43JoinRequest_ValidRequest tests a successful join request
|
||||
func TestHandleNIP43JoinRequest_ValidRequest(t *testing.T) { |
||||
listener, db, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate test user
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Generate invite code
|
||||
code, err := listener.Server.InviteManager.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate invite code: %v", err) |
||||
} |
||||
|
||||
// Create join request event
|
||||
ev := event.New() |
||||
ev.Kind = nip43.KindJoinRequest |
||||
copy(ev.Pubkey, userPubkey) |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.Tags.Append(tag.NewFromAny("claim", code)) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
// Sign event
|
||||
if err = ev.Sign(userSigner); err != nil { |
||||
t.Fatalf("failed to sign event: %v", err) |
||||
} |
||||
|
||||
// Handle join request
|
||||
err = listener.HandleNIP43JoinRequest(ev) |
||||
if err != nil { |
||||
t.Fatalf("failed to handle join request: %v", err) |
||||
} |
||||
|
||||
// Verify user was added to database
|
||||
isMember, err := db.IsNIP43Member(userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership: %v", err) |
||||
} |
||||
if !isMember { |
||||
t.Error("user was not added as member") |
||||
} |
||||
|
||||
// Verify membership details
|
||||
membership, err := db.GetNIP43Membership(userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to get membership: %v", err) |
||||
} |
||||
if membership.InviteCode != code { |
||||
t.Errorf("wrong invite code stored: got %s, want %s", membership.InviteCode, code) |
||||
} |
||||
} |
||||
|
||||
// TestHandleNIP43JoinRequest_InvalidCode tests join request with invalid code
|
||||
func TestHandleNIP43JoinRequest_InvalidCode(t *testing.T) { |
||||
listener, db, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate test user
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Create join request with invalid code
|
||||
ev := event.New() |
||||
ev.Kind = nip43.KindJoinRequest |
||||
copy(ev.Pubkey, userPubkey) |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.Tags.Append(tag.NewFromAny("claim", "invalid-code-123")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
if err = ev.Sign(userSigner); err != nil { |
||||
t.Fatalf("failed to sign event: %v", err) |
||||
} |
||||
|
||||
// Handle join request - should succeed but not add member
|
||||
err = listener.HandleNIP43JoinRequest(ev) |
||||
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 incorrectly added as member with invalid code") |
||||
} |
||||
} |
||||
|
||||
// TestHandleNIP43JoinRequest_DuplicateMember tests join request from existing member
|
||||
func TestHandleNIP43JoinRequest_DuplicateMember(t *testing.T) { |
||||
listener, db, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate test user
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Add user directly to database
|
||||
err = db.AddNIP43Member(userPubkey, "original-code") |
||||
if err != nil { |
||||
t.Fatalf("failed to add member: %v", err) |
||||
} |
||||
|
||||
// Generate new invite code
|
||||
code, err := listener.Server.InviteManager.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate invite code: %v", err) |
||||
} |
||||
|
||||
// Create join request
|
||||
ev := event.New() |
||||
ev.Kind = nip43.KindJoinRequest |
||||
copy(ev.Pubkey, userPubkey) |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.Tags.Append(tag.NewFromAny("claim", code)) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
if err = ev.Sign(userSigner); err != nil { |
||||
t.Fatalf("failed to sign event: %v", err) |
||||
} |
||||
|
||||
// Handle join request - should handle gracefully
|
||||
err = listener.HandleNIP43JoinRequest(ev) |
||||
if err != nil { |
||||
t.Fatalf("handler returned error: %v", err) |
||||
} |
||||
|
||||
// Verify original membership is unchanged
|
||||
membership, err := db.GetNIP43Membership(userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to get membership: %v", err) |
||||
} |
||||
if membership.InviteCode != "original-code" { |
||||
t.Errorf("invite code was changed: got %s, want original-code", membership.InviteCode) |
||||
} |
||||
} |
||||
|
||||
// TestHandleNIP43LeaveRequest_ValidRequest tests a successful leave request
|
||||
func TestHandleNIP43LeaveRequest_ValidRequest(t *testing.T) { |
||||
listener, db, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate test user
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Add user as member
|
||||
err = db.AddNIP43Member(userPubkey, "test-code") |
||||
if err != nil { |
||||
t.Fatalf("failed to add member: %v", err) |
||||
} |
||||
|
||||
// Create leave request
|
||||
ev := event.New() |
||||
ev.Kind = nip43.KindLeaveRequest |
||||
copy(ev.Pubkey, userPubkey) |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
if err = ev.Sign(userSigner); err != nil { |
||||
t.Fatalf("failed to sign event: %v", err) |
||||
} |
||||
|
||||
// Handle leave request
|
||||
err = listener.HandleNIP43LeaveRequest(ev) |
||||
if err != nil { |
||||
t.Fatalf("failed to handle leave request: %v", err) |
||||
} |
||||
|
||||
// Verify user was removed
|
||||
isMember, err := db.IsNIP43Member(userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership: %v", err) |
||||
} |
||||
if isMember { |
||||
t.Error("user was not removed") |
||||
} |
||||
} |
||||
|
||||
// TestHandleNIP43LeaveRequest_NonMember tests leave request from non-member
|
||||
func TestHandleNIP43LeaveRequest_NonMember(t *testing.T) { |
||||
listener, _, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate test user (not a member)
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Create leave request
|
||||
ev := event.New() |
||||
ev.Kind = nip43.KindLeaveRequest |
||||
copy(ev.Pubkey, userPubkey) |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
if err = ev.Sign(userSigner); err != nil { |
||||
t.Fatalf("failed to sign event: %v", err) |
||||
} |
||||
|
||||
// Handle leave request - should handle gracefully
|
||||
err = listener.HandleNIP43LeaveRequest(ev) |
||||
if err != nil { |
||||
t.Fatalf("handler returned error: %v", err) |
||||
} |
||||
} |
||||
|
||||
// TestHandleNIP43InviteRequest_ValidRequest tests invite request from admin
|
||||
func TestHandleNIP43InviteRequest_ValidRequest(t *testing.T) { |
||||
listener, _, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate admin user
|
||||
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 signer: %v", err) |
||||
} |
||||
if err = adminSigner.InitSec(adminSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
adminPubkey := adminSigner.Pub() |
||||
|
||||
// Add admin to server (simulating admin config)
|
||||
listener.Server.Admins = [][]byte{adminPubkey} |
||||
|
||||
// Handle invite request
|
||||
inviteEvent, err := listener.Server.HandleNIP43InviteRequest(adminPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to handle invite request: %v", err) |
||||
} |
||||
|
||||
// Verify invite event
|
||||
if inviteEvent == nil { |
||||
t.Fatal("invite event is nil") |
||||
} |
||||
if inviteEvent.Kind != nip43.KindInviteReq { |
||||
t.Errorf("wrong event kind: got %d, want %d", inviteEvent.Kind, nip43.KindInviteReq) |
||||
} |
||||
|
||||
// Verify claim tag
|
||||
claimTag := inviteEvent.Tags.GetFirst([]byte("claim")) |
||||
if claimTag == nil { |
||||
t.Fatal("missing claim tag") |
||||
} |
||||
if claimTag.Len() < 2 { |
||||
t.Fatal("claim tag has no value") |
||||
} |
||||
} |
||||
|
||||
// TestHandleNIP43InviteRequest_Unauthorized tests invite request from non-admin
|
||||
func TestHandleNIP43InviteRequest_Unauthorized(t *testing.T) { |
||||
listener, _, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate regular user (not admin)
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Handle invite request - should fail
|
||||
_, err = listener.Server.HandleNIP43InviteRequest(userPubkey) |
||||
if err == nil { |
||||
t.Fatal("expected error for unauthorized user") |
||||
} |
||||
} |
||||
|
||||
// TestJoinAndLeaveFlow tests the complete join and leave flow
|
||||
func TestJoinAndLeaveFlow(t *testing.T) { |
||||
listener, db, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
// Generate test user
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate user secret: %v", err) |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Fatalf("failed to create signer: %v", err) |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Fatalf("failed to initialize signer: %v", err) |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Step 1: Generate invite code
|
||||
code, err := listener.Server.InviteManager.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate invite code: %v", err) |
||||
} |
||||
|
||||
// Step 2: User sends join request
|
||||
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(userSigner); err != nil { |
||||
t.Fatalf("failed to sign join event: %v", err) |
||||
} |
||||
|
||||
err = listener.HandleNIP43JoinRequest(joinEv) |
||||
if err != nil { |
||||
t.Fatalf("failed to handle join request: %v", err) |
||||
} |
||||
|
||||
// Verify user is member
|
||||
isMember, err := db.IsNIP43Member(userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership after join: %v", err) |
||||
} |
||||
if !isMember { |
||||
t.Fatal("user is not a member after join") |
||||
} |
||||
|
||||
// Step 3: User sends leave request
|
||||
leaveEv := event.New() |
||||
leaveEv.Kind = nip43.KindLeaveRequest |
||||
copy(leaveEv.Pubkey, userPubkey) |
||||
leaveEv.Tags = tag.NewS() |
||||
leaveEv.Tags.Append(tag.NewFromAny("-")) |
||||
leaveEv.CreatedAt = time.Now().Unix() |
||||
leaveEv.Content = []byte("") |
||||
if err = leaveEv.Sign(userSigner); err != nil { |
||||
t.Fatalf("failed to sign leave event: %v", err) |
||||
} |
||||
|
||||
err = listener.HandleNIP43LeaveRequest(leaveEv) |
||||
if err != nil { |
||||
t.Fatalf("failed to handle leave request: %v", err) |
||||
} |
||||
|
||||
// Verify user is no longer member
|
||||
isMember, err = db.IsNIP43Member(userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership after leave: %v", err) |
||||
} |
||||
if isMember { |
||||
t.Fatal("user is still a member after leave") |
||||
} |
||||
} |
||||
|
||||
// TestMultipleUsersJoining tests multiple users joining concurrently
|
||||
func TestMultipleUsersJoining(t *testing.T) { |
||||
listener, db, cleanup := setupTestListener(t) |
||||
defer cleanup() |
||||
|
||||
userCount := 10 |
||||
done := make(chan bool, userCount) |
||||
|
||||
for i := 0; i < userCount; i++ { |
||||
go func(index int) { |
||||
// Generate user
|
||||
userSecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Errorf("failed to generate user secret %d: %v", index, err) |
||||
done <- false |
||||
return |
||||
} |
||||
userSigner, err := p8k.New() |
||||
if err != nil { |
||||
t.Errorf("failed to create signer %d: %v", index, err) |
||||
done <- false |
||||
return |
||||
} |
||||
if err = userSigner.InitSec(userSecret); err != nil { |
||||
t.Errorf("failed to initialize signer %d: %v", index, err) |
||||
done <- false |
||||
return |
||||
} |
||||
userPubkey := userSigner.Pub() |
||||
|
||||
// Generate invite code
|
||||
code, err := listener.Server.InviteManager.GenerateCode() |
||||
if err != nil { |
||||
t.Errorf("failed to generate invite code %d: %v", index, err) |
||||
done <- false |
||||
return |
||||
} |
||||
|
||||
// Create join request
|
||||
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(userSigner); err != nil { |
||||
t.Errorf("failed to sign event %d: %v", index, err) |
||||
done <- false |
||||
return |
||||
} |
||||
|
||||
// Handle join request
|
||||
if err = listener.HandleNIP43JoinRequest(joinEv); err != nil { |
||||
t.Errorf("failed to handle join request %d: %v", index, err) |
||||
done <- false |
||||
return |
||||
} |
||||
|
||||
done <- true |
||||
}(i) |
||||
} |
||||
|
||||
// Wait for all goroutines
|
||||
successCount := 0 |
||||
for i := 0; i < userCount; i++ { |
||||
if <-done { |
||||
successCount++ |
||||
} |
||||
} |
||||
|
||||
if successCount != userCount { |
||||
t.Errorf("not all users joined successfully: %d/%d", successCount, userCount) |
||||
} |
||||
|
||||
// Verify member count
|
||||
members, err := db.GetAllNIP43Members() |
||||
if err != nil { |
||||
t.Fatalf("failed to get all members: %v", err) |
||||
} |
||||
|
||||
if len(members) != successCount { |
||||
t.Errorf("wrong member count: got %d, want %d", len(members), successCount) |
||||
} |
||||
} |
||||
@ -0,0 +1,549 @@
@@ -0,0 +1,549 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"next.orly.dev/pkg/interfaces/signer/p8k" |
||||
"context" |
||||
"encoding/json" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"next.orly.dev/app/config" |
||||
"next.orly.dev/pkg/crypto/keys" |
||||
"next.orly.dev/pkg/database" |
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/tag" |
||||
"next.orly.dev/pkg/protocol/nip43" |
||||
"next.orly.dev/pkg/protocol/publish" |
||||
"next.orly.dev/pkg/protocol/relayinfo" |
||||
) |
||||
|
||||
// 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() |
||||
|
||||
server := &Server{ |
||||
Ctx: ctx, |
||||
Config: cfg, |
||||
D: db, |
||||
publishers: publish.New(NewPublisher(ctx)), |
||||
Admins: [][]byte{adminPubkey}, |
||||
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry), |
||||
cfg: cfg, |
||||
db: db, |
||||
} |
||||
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.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 := &Listener{ |
||||
Server: server, |
||||
ctx: server.Ctx, |
||||
} |
||||
err = listener.HandleNIP43JoinRequest(joinEv) |
||||
if err != nil { |
||||
t.Fatalf("failed to handle join request: %v", err) |
||||
} |
||||
|
||||
// Step 4: Verify membership
|
||||
isMember, err := server.D.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.D.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 := &Listener{ |
||||
Server: server, |
||||
ctx: server.Ctx, |
||||
} |
||||
|
||||
// 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.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.D.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.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.D.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 := &Listener{ |
||||
Server: server, |
||||
ctx: server.Ctx, |
||||
} |
||||
|
||||
// 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.D.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) |
||||
|
||||
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
|
||||
} |
||||
|
||||
ctx := context.Background() |
||||
|
||||
server := &Server{ |
||||
Ctx: ctx, |
||||
Config: cfg, |
||||
D: db, |
||||
publishers: publish.New(NewPublisher(ctx)), |
||||
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry), |
||||
cfg: cfg, |
||||
db: db, |
||||
} |
||||
|
||||
listener := &Listener{ |
||||
Server: server, |
||||
ctx: ctx, |
||||
} |
||||
|
||||
// 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.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 := &Listener{ |
||||
Server: server, |
||||
ctx: server.Ctx, |
||||
} |
||||
|
||||
// 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.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.D.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) |
||||
|
||||
db, err := database.Open(filepath.Join(tempDir, "test.db"), "error") |
||||
if err != nil { |
||||
b.Fatalf("failed to open database: %v", err) |
||||
} |
||||
defer db.Close() |
||||
|
||||
cfg := &config.C{ |
||||
NIP43Enabled: true, |
||||
NIP43InviteExpiry: 24 * time.Hour, |
||||
} |
||||
|
||||
ctx := context.Background() |
||||
|
||||
server := &Server{ |
||||
Ctx: ctx, |
||||
Config: cfg, |
||||
D: db, |
||||
publishers: publish.New(NewPublisher(ctx)), |
||||
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry), |
||||
cfg: cfg, |
||||
db: db, |
||||
} |
||||
|
||||
listener := &Listener{ |
||||
Server: server, |
||||
ctx: ctx, |
||||
} |
||||
|
||||
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.Append(tag.NewFromAny("-")) |
||||
joinEv.Tags.Append(tag.NewFromAny("claim", code)) |
||||
joinEv.CreatedAt = time.Now().Unix() |
||||
joinEv.Content = []byte("") |
||||
joinEv.Sign(signer) |
||||
|
||||
listener.HandleNIP43JoinRequest(joinEv) |
||||
} |
||||
} |
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 379 KiB |
@ -1,69 +0,0 @@
@@ -1,69 +0,0 @@
|
||||
html, |
||||
body { |
||||
position: relative; |
||||
width: 100%; |
||||
height: 100%; |
||||
} |
||||
|
||||
body { |
||||
color: #333; |
||||
margin: 0; |
||||
padding: 8px; |
||||
box-sizing: border-box; |
||||
font-family: |
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, |
||||
Cantarell, "Helvetica Neue", sans-serif; |
||||
} |
||||
|
||||
a { |
||||
color: rgb(0, 100, 200); |
||||
text-decoration: none; |
||||
} |
||||
|
||||
a:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
a:visited { |
||||
color: rgb(0, 80, 160); |
||||
} |
||||
|
||||
label { |
||||
display: block; |
||||
} |
||||
|
||||
input, |
||||
button, |
||||
select, |
||||
textarea { |
||||
font-family: inherit; |
||||
font-size: inherit; |
||||
-webkit-padding: 0.4em 0; |
||||
padding: 0.4em; |
||||
margin: 0 0 0.5em 0; |
||||
box-sizing: border-box; |
||||
border: 1px solid #ccc; |
||||
border-radius: 2px; |
||||
} |
||||
|
||||
input:disabled { |
||||
color: #ccc; |
||||
} |
||||
|
||||
button { |
||||
color: #333; |
||||
background-color: #f4f4f4; |
||||
outline: none; |
||||
} |
||||
|
||||
button:disabled { |
||||
color: #999; |
||||
} |
||||
|
||||
button:not(:disabled):active { |
||||
background-color: #ddd; |
||||
} |
||||
|
||||
button:focus { |
||||
border-color: #666; |
||||
} |
||||
@ -1,17 +1 @@
@@ -1,17 +1 @@
|
||||
<!doctype html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="utf-8" /> |
||||
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
||||
|
||||
<title>ORLY?</title> |
||||
|
||||
<link rel="icon" type="image/png" href="/favicon.png" /> |
||||
<link rel="stylesheet" href="/global.css" /> |
||||
<link rel="stylesheet" href="/bundle.css" /> |
||||
|
||||
<script defer src="/bundle.js"></script> |
||||
</head> |
||||
|
||||
<body></body> |
||||
</html> |
||||
test |
||||
|
||||
|
Before Width: | Height: | Size: 514 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,259 @@
@@ -0,0 +1,259 @@
|
||||
package database |
||||
|
||||
import ( |
||||
"encoding/binary" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/dgraph-io/badger/v4" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
) |
||||
|
||||
// NIP43Membership represents membership metadata for NIP-43
|
||||
type NIP43Membership struct { |
||||
Pubkey []byte |
||||
AddedAt time.Time |
||||
InviteCode string |
||||
} |
||||
|
||||
// Database key prefixes for NIP-43
|
||||
const ( |
||||
nip43MemberPrefix = "nip43:member:" |
||||
nip43InvitePrefix = "nip43:invite:" |
||||
) |
||||
|
||||
// AddNIP43Member adds a member to the NIP-43 membership list
|
||||
func (d *D) AddNIP43Member(pubkey []byte, inviteCode string) error { |
||||
if len(pubkey) != 32 { |
||||
return fmt.Errorf("invalid pubkey length: %d", len(pubkey)) |
||||
} |
||||
|
||||
key := append([]byte(nip43MemberPrefix), pubkey...) |
||||
|
||||
// Create membership record
|
||||
membership := NIP43Membership{ |
||||
Pubkey: pubkey, |
||||
AddedAt: time.Now(), |
||||
InviteCode: inviteCode, |
||||
} |
||||
|
||||
// Serialize membership data
|
||||
val := serializeNIP43Membership(membership) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Set(key, val) |
||||
}) |
||||
} |
||||
|
||||
// RemoveNIP43Member removes a member from the NIP-43 membership list
|
||||
func (d *D) RemoveNIP43Member(pubkey []byte) error { |
||||
if len(pubkey) != 32 { |
||||
return fmt.Errorf("invalid pubkey length: %d", len(pubkey)) |
||||
} |
||||
|
||||
key := append([]byte(nip43MemberPrefix), pubkey...) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Delete(key) |
||||
}) |
||||
} |
||||
|
||||
// IsNIP43Member checks if a pubkey is a NIP-43 member
|
||||
func (d *D) IsNIP43Member(pubkey []byte) (isMember bool, err error) { |
||||
if len(pubkey) != 32 { |
||||
return false, fmt.Errorf("invalid pubkey length: %d", len(pubkey)) |
||||
} |
||||
|
||||
key := append([]byte(nip43MemberPrefix), pubkey...) |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
_, err := txn.Get(key) |
||||
if err == badger.ErrKeyNotFound { |
||||
isMember = false |
||||
return nil |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
isMember = true |
||||
return nil |
||||
}) |
||||
|
||||
return isMember, err |
||||
} |
||||
|
||||
// GetNIP43Membership retrieves membership details for a pubkey
|
||||
func (d *D) GetNIP43Membership(pubkey []byte) (*NIP43Membership, error) { |
||||
if len(pubkey) != 32 { |
||||
return nil, fmt.Errorf("invalid pubkey length: %d", len(pubkey)) |
||||
} |
||||
|
||||
key := append([]byte(nip43MemberPrefix), pubkey...) |
||||
var membership *NIP43Membership |
||||
|
||||
err := d.DB.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get(key) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return item.Value(func(val []byte) error { |
||||
membership = deserializeNIP43Membership(val) |
||||
return nil |
||||
}) |
||||
}) |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return membership, nil |
||||
} |
||||
|
||||
// GetAllNIP43Members returns all NIP-43 members
|
||||
func (d *D) GetAllNIP43Members() ([][]byte, error) { |
||||
var members [][]byte |
||||
prefix := []byte(nip43MemberPrefix) |
||||
|
||||
err := d.DB.View(func(txn *badger.Txn) error { |
||||
opts := badger.DefaultIteratorOptions |
||||
opts.Prefix = prefix |
||||
opts.PrefetchValues = false // We only need keys
|
||||
|
||||
it := txn.NewIterator(opts) |
||||
defer it.Close() |
||||
|
||||
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||
item := it.Item() |
||||
key := item.Key() |
||||
// Extract pubkey from key (skip prefix)
|
||||
pubkey := make([]byte, 32) |
||||
copy(pubkey, key[len(prefix):]) |
||||
members = append(members, pubkey) |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
|
||||
return members, err |
||||
} |
||||
|
||||
// StoreInviteCode stores an invite code with expiry
|
||||
func (d *D) StoreInviteCode(code string, expiresAt time.Time) error { |
||||
key := append([]byte(nip43InvitePrefix), []byte(code)...) |
||||
|
||||
// Serialize expiry time as unix timestamp
|
||||
val := make([]byte, 8) |
||||
binary.BigEndian.PutUint64(val, uint64(expiresAt.Unix())) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
entry := badger.NewEntry(key, val).WithTTL(time.Until(expiresAt)) |
||||
return txn.SetEntry(entry) |
||||
}) |
||||
} |
||||
|
||||
// ValidateInviteCode checks if an invite code is valid and not expired
|
||||
func (d *D) ValidateInviteCode(code string) (valid bool, err error) { |
||||
key := append([]byte(nip43InvitePrefix), []byte(code)...) |
||||
|
||||
err = d.DB.View(func(txn *badger.Txn) error { |
||||
item, err := txn.Get(key) |
||||
if err == badger.ErrKeyNotFound { |
||||
valid = false |
||||
return nil |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return item.Value(func(val []byte) error { |
||||
if len(val) != 8 { |
||||
return fmt.Errorf("invalid invite code value") |
||||
} |
||||
expiresAt := int64(binary.BigEndian.Uint64(val)) |
||||
valid = time.Now().Unix() < expiresAt |
||||
return nil |
||||
}) |
||||
}) |
||||
|
||||
return valid, err |
||||
} |
||||
|
||||
// DeleteInviteCode removes an invite code (after use)
|
||||
func (d *D) DeleteInviteCode(code string) error { |
||||
key := append([]byte(nip43InvitePrefix), []byte(code)...) |
||||
|
||||
return d.DB.Update(func(txn *badger.Txn) error { |
||||
return txn.Delete(key) |
||||
}) |
||||
} |
||||
|
||||
// Helper functions for serialization
|
||||
|
||||
func serializeNIP43Membership(m NIP43Membership) []byte { |
||||
// Format: [pubkey(32)] [timestamp(8)] [invite_code_len(2)] [invite_code]
|
||||
codeBytes := []byte(m.InviteCode) |
||||
codeLen := len(codeBytes) |
||||
|
||||
buf := make([]byte, 32+8+2+codeLen) |
||||
|
||||
// Copy pubkey
|
||||
copy(buf[0:32], m.Pubkey) |
||||
|
||||
// Write timestamp
|
||||
binary.BigEndian.PutUint64(buf[32:40], uint64(m.AddedAt.Unix())) |
||||
|
||||
// Write invite code length
|
||||
binary.BigEndian.PutUint16(buf[40:42], uint16(codeLen)) |
||||
|
||||
// Write invite code
|
||||
copy(buf[42:], codeBytes) |
||||
|
||||
return buf |
||||
} |
||||
|
||||
func deserializeNIP43Membership(data []byte) *NIP43Membership { |
||||
if len(data) < 42 { |
||||
return nil |
||||
} |
||||
|
||||
m := &NIP43Membership{} |
||||
|
||||
// Read pubkey
|
||||
m.Pubkey = make([]byte, 32) |
||||
copy(m.Pubkey, data[0:32]) |
||||
|
||||
// Read timestamp
|
||||
timestamp := binary.BigEndian.Uint64(data[32:40]) |
||||
m.AddedAt = time.Unix(int64(timestamp), 0) |
||||
|
||||
// Read invite code
|
||||
codeLen := binary.BigEndian.Uint16(data[40:42]) |
||||
if len(data) >= 42+int(codeLen) { |
||||
m.InviteCode = string(data[42 : 42+codeLen]) |
||||
} |
||||
|
||||
return m |
||||
} |
||||
|
||||
// PublishNIP43MembershipEvent publishes membership change events
|
||||
func (d *D) PublishNIP43MembershipEvent(kind int, pubkey []byte) error { |
||||
log.I.F("publishing NIP-43 event kind %d for pubkey %s", kind, hex.Enc(pubkey)) |
||||
|
||||
// Get relay identity
|
||||
relaySecret, err := d.GetOrCreateRelayIdentitySecret() |
||||
if chk.E(err) { |
||||
return err |
||||
} |
||||
|
||||
// This would integrate with the event publisher
|
||||
// For now, just log it
|
||||
log.D.F("would publish kind %d event for member %s", kind, hex.Enc(pubkey)) |
||||
|
||||
// The actual publishing will be done by the handler
|
||||
_ = relaySecret |
||||
|
||||
return nil |
||||
} |
||||
@ -0,0 +1,406 @@
@@ -0,0 +1,406 @@
|
||||
package database |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
func setupNIP43TestDB(t *testing.T) (*D, func()) { |
||||
tempDir, err := os.MkdirTemp("", "nip43_test_*") |
||||
if err != nil { |
||||
t.Fatalf("failed to create temp dir: %v", err) |
||||
} |
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
db, err := New(ctx, cancel, tempDir, "info") |
||||
if err != nil { |
||||
os.RemoveAll(tempDir) |
||||
t.Fatalf("failed to open database: %v", err) |
||||
} |
||||
|
||||
cleanup := func() { |
||||
db.Close() |
||||
os.RemoveAll(tempDir) |
||||
} |
||||
|
||||
return db, cleanup |
||||
} |
||||
|
||||
// TestAddNIP43Member tests adding a member
|
||||
func TestAddNIP43Member(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
pubkey := make([]byte, 32) |
||||
for i := range pubkey { |
||||
pubkey[i] = byte(i) |
||||
} |
||||
inviteCode := "test-invite-123" |
||||
|
||||
err := db.AddNIP43Member(pubkey, inviteCode) |
||||
if err != nil { |
||||
t.Fatalf("failed to add member: %v", err) |
||||
} |
||||
|
||||
// Verify member was added
|
||||
isMember, err := db.IsNIP43Member(pubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership: %v", err) |
||||
} |
||||
if !isMember { |
||||
t.Error("member was not added") |
||||
} |
||||
} |
||||
|
||||
// TestAddNIP43Member_InvalidPubkey tests adding member with invalid pubkey
|
||||
func TestAddNIP43Member_InvalidPubkey(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
// Test with wrong length
|
||||
invalidPubkey := make([]byte, 16) |
||||
err := db.AddNIP43Member(invalidPubkey, "test-code") |
||||
if err == nil { |
||||
t.Error("expected error for invalid pubkey length") |
||||
} |
||||
} |
||||
|
||||
// TestRemoveNIP43Member tests removing a member
|
||||
func TestRemoveNIP43Member(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
pubkey := make([]byte, 32) |
||||
for i := range pubkey { |
||||
pubkey[i] = byte(i) |
||||
} |
||||
|
||||
// Add member
|
||||
err := db.AddNIP43Member(pubkey, "test-code") |
||||
if err != nil { |
||||
t.Fatalf("failed to add member: %v", err) |
||||
} |
||||
|
||||
// Remove member
|
||||
err = db.RemoveNIP43Member(pubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to remove member: %v", err) |
||||
} |
||||
|
||||
// Verify member was removed
|
||||
isMember, err := db.IsNIP43Member(pubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership: %v", err) |
||||
} |
||||
if isMember { |
||||
t.Error("member was not removed") |
||||
} |
||||
} |
||||
|
||||
// TestIsNIP43Member tests membership checking
|
||||
func TestIsNIP43Member(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
pubkey := make([]byte, 32) |
||||
for i := range pubkey { |
||||
pubkey[i] = byte(i) |
||||
} |
||||
|
||||
// Check non-existent member
|
||||
isMember, err := db.IsNIP43Member(pubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership: %v", err) |
||||
} |
||||
if isMember { |
||||
t.Error("non-existent member reported as member") |
||||
} |
||||
|
||||
// Add member
|
||||
err = db.AddNIP43Member(pubkey, "test-code") |
||||
if err != nil { |
||||
t.Fatalf("failed to add member: %v", err) |
||||
} |
||||
|
||||
// Check existing member
|
||||
isMember, err = db.IsNIP43Member(pubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to check membership: %v", err) |
||||
} |
||||
if !isMember { |
||||
t.Error("existing member not found") |
||||
} |
||||
} |
||||
|
||||
// TestGetNIP43Membership tests retrieving membership details
|
||||
func TestGetNIP43Membership(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
pubkey := make([]byte, 32) |
||||
for i := range pubkey { |
||||
pubkey[i] = byte(i) |
||||
} |
||||
inviteCode := "test-invite-abc123" |
||||
|
||||
// Add member
|
||||
beforeAdd := time.Now() |
||||
err := db.AddNIP43Member(pubkey, inviteCode) |
||||
if err != nil { |
||||
t.Fatalf("failed to add member: %v", err) |
||||
} |
||||
afterAdd := time.Now() |
||||
|
||||
// Get membership
|
||||
membership, err := db.GetNIP43Membership(pubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to get membership: %v", err) |
||||
} |
||||
|
||||
// Verify details
|
||||
if len(membership.Pubkey) != 32 { |
||||
t.Errorf("wrong pubkey length: got %d, want 32", len(membership.Pubkey)) |
||||
} |
||||
for i := range pubkey { |
||||
if membership.Pubkey[i] != pubkey[i] { |
||||
t.Errorf("pubkey mismatch at index %d", i) |
||||
break |
||||
} |
||||
} |
||||
|
||||
if membership.InviteCode != inviteCode { |
||||
t.Errorf("invite code mismatch: got %s, want %s", membership.InviteCode, inviteCode) |
||||
} |
||||
|
||||
// Allow some tolerance for timestamp (database operations may take time)
|
||||
if membership.AddedAt.Before(beforeAdd.Add(-5*time.Second)) || membership.AddedAt.After(afterAdd.Add(5*time.Second)) { |
||||
t.Errorf("AddedAt timestamp out of expected range: got %v, expected between %v and %v", |
||||
membership.AddedAt, beforeAdd, afterAdd) |
||||
} |
||||
} |
||||
|
||||
// TestGetAllNIP43Members tests retrieving all members
|
||||
func TestGetAllNIP43Members(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
// Add multiple members
|
||||
memberCount := 5 |
||||
for i := 0; i < memberCount; i++ { |
||||
pubkey := make([]byte, 32) |
||||
for j := range pubkey { |
||||
pubkey[j] = byte(i*10 + j) |
||||
} |
||||
err := db.AddNIP43Member(pubkey, "code-"+string(rune(i))) |
||||
if err != nil { |
||||
t.Fatalf("failed to add member %d: %v", i, err) |
||||
} |
||||
} |
||||
|
||||
// Get all members
|
||||
members, err := db.GetAllNIP43Members() |
||||
if err != nil { |
||||
t.Fatalf("failed to get all members: %v", err) |
||||
} |
||||
|
||||
if len(members) != memberCount { |
||||
t.Errorf("wrong member count: got %d, want %d", len(members), memberCount) |
||||
} |
||||
|
||||
// Verify each member has valid pubkey
|
||||
for i, member := range members { |
||||
if len(member) != 32 { |
||||
t.Errorf("member %d has invalid pubkey length: %d", i, len(member)) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// TestStoreInviteCode tests storing invite codes
|
||||
func TestStoreInviteCode(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
code := "test-invite-xyz789" |
||||
expiresAt := time.Now().Add(24 * time.Hour) |
||||
|
||||
err := db.StoreInviteCode(code, expiresAt) |
||||
if err != nil { |
||||
t.Fatalf("failed to store invite code: %v", err) |
||||
} |
||||
|
||||
// Validate the code
|
||||
valid, err := db.ValidateInviteCode(code) |
||||
if err != nil { |
||||
t.Fatalf("failed to validate invite code: %v", err) |
||||
} |
||||
if !valid { |
||||
t.Error("stored invite code is not valid") |
||||
} |
||||
} |
||||
|
||||
// TestValidateInviteCode_Expired tests expired invite code handling
|
||||
func TestValidateInviteCode_Expired(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
code := "expired-code" |
||||
expiresAt := time.Now().Add(-1 * time.Hour) // Already expired
|
||||
|
||||
err := db.StoreInviteCode(code, expiresAt) |
||||
if err != nil { |
||||
t.Fatalf("failed to store invite code: %v", err) |
||||
} |
||||
|
||||
// Validate the code - should be invalid because it's expired
|
||||
valid, err := db.ValidateInviteCode(code) |
||||
if err != nil { |
||||
t.Fatalf("failed to validate invite code: %v", err) |
||||
} |
||||
if valid { |
||||
t.Error("expired invite code reported as valid") |
||||
} |
||||
} |
||||
|
||||
// TestValidateInviteCode_NonExistent tests non-existent code validation
|
||||
func TestValidateInviteCode_NonExistent(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
valid, err := db.ValidateInviteCode("non-existent-code") |
||||
if err != nil { |
||||
t.Fatalf("unexpected error: %v", err) |
||||
} |
||||
if valid { |
||||
t.Error("non-existent code reported as valid") |
||||
} |
||||
} |
||||
|
||||
// TestDeleteInviteCode tests deleting invite codes
|
||||
func TestDeleteInviteCode(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
code := "delete-me-code" |
||||
expiresAt := time.Now().Add(24 * time.Hour) |
||||
|
||||
// Store code
|
||||
err := db.StoreInviteCode(code, expiresAt) |
||||
if err != nil { |
||||
t.Fatalf("failed to store invite code: %v", err) |
||||
} |
||||
|
||||
// Verify it exists
|
||||
valid, err := db.ValidateInviteCode(code) |
||||
if err != nil { |
||||
t.Fatalf("failed to validate invite code: %v", err) |
||||
} |
||||
if !valid { |
||||
t.Error("stored code is not valid") |
||||
} |
||||
|
||||
// Delete code
|
||||
err = db.DeleteInviteCode(code) |
||||
if err != nil { |
||||
t.Fatalf("failed to delete invite code: %v", err) |
||||
} |
||||
|
||||
// Verify it's gone
|
||||
valid, err = db.ValidateInviteCode(code) |
||||
if err != nil { |
||||
t.Fatalf("failed to validate after delete: %v", err) |
||||
} |
||||
if valid { |
||||
t.Error("deleted code still valid") |
||||
} |
||||
} |
||||
|
||||
// TestNIP43Membership_Serialization tests membership serialization
|
||||
func TestNIP43Membership_Serialization(t *testing.T) { |
||||
pubkey := make([]byte, 32) |
||||
for i := range pubkey { |
||||
pubkey[i] = byte(i) |
||||
} |
||||
|
||||
original := NIP43Membership{ |
||||
Pubkey: pubkey, |
||||
AddedAt: time.Now(), |
||||
InviteCode: "test-code-123", |
||||
} |
||||
|
||||
// Serialize
|
||||
data := serializeNIP43Membership(original) |
||||
|
||||
// Deserialize
|
||||
deserialized := deserializeNIP43Membership(data) |
||||
|
||||
// Verify
|
||||
if deserialized == nil { |
||||
t.Fatal("deserialization returned nil") |
||||
} |
||||
|
||||
if len(deserialized.Pubkey) != 32 { |
||||
t.Errorf("wrong pubkey length: got %d, want 32", len(deserialized.Pubkey)) |
||||
} |
||||
|
||||
for i := range pubkey { |
||||
if deserialized.Pubkey[i] != pubkey[i] { |
||||
t.Errorf("pubkey mismatch at index %d", i) |
||||
break |
||||
} |
||||
} |
||||
|
||||
if deserialized.InviteCode != original.InviteCode { |
||||
t.Errorf("invite code mismatch: got %s, want %s", deserialized.InviteCode, original.InviteCode) |
||||
} |
||||
|
||||
// Allow 1 second tolerance for timestamp comparison (due to Unix conversion)
|
||||
timeDiff := deserialized.AddedAt.Sub(original.AddedAt) |
||||
if timeDiff < -1*time.Second || timeDiff > 1*time.Second { |
||||
t.Errorf("timestamp mismatch: got %v, want %v (diff: %v)", deserialized.AddedAt, original.AddedAt, timeDiff) |
||||
} |
||||
} |
||||
|
||||
// TestNIP43Membership_ConcurrentAccess tests concurrent access to membership
|
||||
func TestNIP43Membership_ConcurrentAccess(t *testing.T) { |
||||
db, cleanup := setupNIP43TestDB(t) |
||||
defer cleanup() |
||||
|
||||
const goroutines = 10 |
||||
const membersPerGoroutine = 5 |
||||
|
||||
done := make(chan bool, goroutines) |
||||
|
||||
// Add members concurrently
|
||||
for g := 0; g < goroutines; g++ { |
||||
go func(offset int) { |
||||
for i := 0; i < membersPerGoroutine; i++ { |
||||
pubkey := make([]byte, 32) |
||||
for j := range pubkey { |
||||
pubkey[j] = byte((offset*membersPerGoroutine+i)*10 + j) |
||||
} |
||||
if err := db.AddNIP43Member(pubkey, "code"); err != nil { |
||||
t.Errorf("failed to add member: %v", err) |
||||
} |
||||
} |
||||
done <- true |
||||
}(g) |
||||
} |
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < goroutines; i++ { |
||||
<-done |
||||
} |
||||
|
||||
// Verify all members were added
|
||||
members, err := db.GetAllNIP43Members() |
||||
if err != nil { |
||||
t.Fatalf("failed to get all members: %v", err) |
||||
} |
||||
|
||||
expected := goroutines * membersPerGoroutine |
||||
if len(members) != expected { |
||||
t.Errorf("wrong member count: got %d, want %d", len(members), expected) |
||||
} |
||||
} |
||||
@ -0,0 +1,312 @@
@@ -0,0 +1,312 @@
|
||||
package nip43 |
||||
|
||||
import ( |
||||
"crypto/rand" |
||||
"encoding/base64" |
||||
"sync" |
||||
"time" |
||||
|
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/hex" |
||||
"next.orly.dev/pkg/encoders/tag" |
||||
"next.orly.dev/pkg/interfaces/signer/p8k" |
||||
) |
||||
|
||||
// Event kinds defined by NIP-43
|
||||
const ( |
||||
KindMemberList = 13534 // Membership list published by relay
|
||||
KindAddUser = 8000 // Add user event published by relay
|
||||
KindRemoveUser = 8001 // Remove user event published by relay
|
||||
KindJoinRequest = 28934 // Join request sent by user
|
||||
KindInviteReq = 28935 // Invite request (ephemeral)
|
||||
KindLeaveRequest = 28936 // Leave request sent by user
|
||||
) |
||||
|
||||
// InviteCode represents a claim/invite code for relay access
|
||||
type InviteCode struct { |
||||
Code string |
||||
ExpiresAt time.Time |
||||
UsedBy []byte // pubkey that used this code, nil if unused
|
||||
CreatedAt time.Time |
||||
} |
||||
|
||||
// InviteManager manages invite codes for NIP-43
|
||||
type InviteManager struct { |
||||
mu sync.RWMutex |
||||
codes map[string]*InviteCode |
||||
expiry time.Duration |
||||
} |
||||
|
||||
// NewInviteManager creates a new invite code manager
|
||||
func NewInviteManager(expiryDuration time.Duration) *InviteManager { |
||||
if expiryDuration == 0 { |
||||
expiryDuration = 24 * time.Hour // Default: 24 hours
|
||||
} |
||||
return &InviteManager{ |
||||
codes: make(map[string]*InviteCode), |
||||
expiry: expiryDuration, |
||||
} |
||||
} |
||||
|
||||
// GenerateCode creates a new invite code
|
||||
func (im *InviteManager) GenerateCode() (code string, err error) { |
||||
// Generate 32 random bytes
|
||||
b := make([]byte, 32) |
||||
if _, err = rand.Read(b); err != nil { |
||||
return |
||||
} |
||||
code = base64.URLEncoding.EncodeToString(b) |
||||
|
||||
im.mu.Lock() |
||||
defer im.mu.Unlock() |
||||
|
||||
im.codes[code] = &InviteCode{ |
||||
Code: code, |
||||
CreatedAt: time.Now(), |
||||
ExpiresAt: time.Now().Add(im.expiry), |
||||
} |
||||
|
||||
return code, nil |
||||
} |
||||
|
||||
// ValidateAndConsume validates an invite code and marks it as used by the given pubkey
|
||||
func (im *InviteManager) ValidateAndConsume(code string, pubkey []byte) (valid bool, reason string) { |
||||
im.mu.Lock() |
||||
defer im.mu.Unlock() |
||||
|
||||
invite, exists := im.codes[code] |
||||
if !exists { |
||||
return false, "invalid invite code" |
||||
} |
||||
|
||||
if time.Now().After(invite.ExpiresAt) { |
||||
delete(im.codes, code) |
||||
return false, "invite code expired" |
||||
} |
||||
|
||||
if invite.UsedBy != nil { |
||||
return false, "invite code already used" |
||||
} |
||||
|
||||
// Mark as used
|
||||
invite.UsedBy = make([]byte, len(pubkey)) |
||||
copy(invite.UsedBy, pubkey) |
||||
|
||||
return true, "" |
||||
} |
||||
|
||||
// CleanupExpired removes expired invite codes
|
||||
func (im *InviteManager) CleanupExpired() { |
||||
im.mu.Lock() |
||||
defer im.mu.Unlock() |
||||
|
||||
now := time.Now() |
||||
for code, invite := range im.codes { |
||||
if now.After(invite.ExpiresAt) { |
||||
delete(im.codes, code) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// BuildMemberListEvent creates a kind 13534 membership list event
|
||||
// relaySecretKey: the relay's identity secret key (32 bytes)
|
||||
// members: list of member pubkeys (32 bytes each)
|
||||
func BuildMemberListEvent(relaySecretKey []byte, members [][]byte) (*event.E, error) { |
||||
// Create signer
|
||||
signer, err := p8k.New() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if err = signer.InitSec(relaySecretKey); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
ev := event.New() |
||||
ev.Kind = KindMemberList |
||||
copy(ev.Pubkey, signer.Pub()) |
||||
|
||||
// Initialize tags
|
||||
ev.Tags = tag.NewS() |
||||
|
||||
// Add NIP-70 `-` tag
|
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
|
||||
// Add member tags
|
||||
for _, member := range members { |
||||
if len(member) == 32 { |
||||
ev.Tags.Append(tag.NewFromAny("member", hex.Enc(member))) |
||||
} |
||||
} |
||||
|
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
// Sign the event
|
||||
if err := ev.Sign(signer); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return ev, nil |
||||
} |
||||
|
||||
// BuildAddUserEvent creates a kind 8000 add user event
|
||||
func BuildAddUserEvent(relaySecretKey []byte, userPubkey []byte) (*event.E, error) { |
||||
// Create signer
|
||||
signer, err := p8k.New() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if err = signer.InitSec(relaySecretKey); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
ev := event.New() |
||||
ev.Kind = KindAddUser |
||||
copy(ev.Pubkey, signer.Pub()) |
||||
|
||||
// Initialize tags
|
||||
ev.Tags = tag.NewS() |
||||
|
||||
// Add NIP-70 `-` tag
|
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
|
||||
// Add p tag for the user
|
||||
if len(userPubkey) == 32 { |
||||
ev.Tags.Append(tag.NewFromAny("p", hex.Enc(userPubkey))) |
||||
} |
||||
|
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
// Sign the event
|
||||
if err := ev.Sign(signer); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return ev, nil |
||||
} |
||||
|
||||
// BuildRemoveUserEvent creates a kind 8001 remove user event
|
||||
func BuildRemoveUserEvent(relaySecretKey []byte, userPubkey []byte) (*event.E, error) { |
||||
// Create signer
|
||||
signer, err := p8k.New() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if err = signer.InitSec(relaySecretKey); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
ev := event.New() |
||||
ev.Kind = KindRemoveUser |
||||
copy(ev.Pubkey, signer.Pub()) |
||||
|
||||
// Initialize tags
|
||||
ev.Tags = tag.NewS() |
||||
|
||||
// Add NIP-70 `-` tag
|
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
|
||||
// Add p tag for the user
|
||||
if len(userPubkey) == 32 { |
||||
ev.Tags.Append(tag.NewFromAny("p", hex.Enc(userPubkey))) |
||||
} |
||||
|
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
// Sign the event
|
||||
if err := ev.Sign(signer); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return ev, nil |
||||
} |
||||
|
||||
// BuildInviteEvent creates a kind 28935 invite event (ephemeral)
|
||||
func BuildInviteEvent(relaySecretKey []byte, inviteCode string) (*event.E, error) { |
||||
// Create signer
|
||||
signer, err := p8k.New() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if err = signer.InitSec(relaySecretKey); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
ev := event.New() |
||||
ev.Kind = KindInviteReq |
||||
copy(ev.Pubkey, signer.Pub()) |
||||
|
||||
// Initialize tags
|
||||
ev.Tags = tag.NewS() |
||||
|
||||
// Add NIP-70 `-` tag
|
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
|
||||
// Add claim tag
|
||||
ev.Tags.Append(tag.NewFromAny("claim", inviteCode)) |
||||
|
||||
ev.CreatedAt = time.Now().Unix() |
||||
ev.Content = []byte("") |
||||
|
||||
// Sign the event
|
||||
if err := ev.Sign(signer); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return ev, nil |
||||
} |
||||
|
||||
// ValidateJoinRequest validates a kind 28934 join request event
|
||||
func ValidateJoinRequest(ev *event.E) (inviteCode string, valid bool, reason string) { |
||||
// Must be kind 28934
|
||||
if ev.Kind != KindJoinRequest { |
||||
return "", false, "invalid event kind" |
||||
} |
||||
|
||||
// Must have NIP-70 `-` tag
|
||||
hasMinusTag := ev.Tags.GetFirst([]byte("-")) != nil |
||||
if !hasMinusTag { |
||||
return "", false, "missing NIP-70 `-` tag" |
||||
} |
||||
|
||||
// Must have claim tag
|
||||
claimTag := ev.Tags.GetFirst([]byte("claim")) |
||||
if claimTag != nil && claimTag.Len() >= 2 { |
||||
inviteCode = string(claimTag.T[1]) |
||||
} |
||||
if inviteCode == "" { |
||||
return "", false, "missing claim tag" |
||||
} |
||||
|
||||
// Check timestamp (must be recent, within +/- 10 minutes)
|
||||
now := time.Now().Unix() |
||||
if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 { |
||||
return inviteCode, false, "timestamp out of range" |
||||
} |
||||
|
||||
return inviteCode, true, "" |
||||
} |
||||
|
||||
// ValidateLeaveRequest validates a kind 28936 leave request event
|
||||
func ValidateLeaveRequest(ev *event.E) (valid bool, reason string) { |
||||
// Must be kind 28936
|
||||
if ev.Kind != KindLeaveRequest { |
||||
return false, "invalid event kind" |
||||
} |
||||
|
||||
// Must have NIP-70 `-` tag
|
||||
hasMinusTag := ev.Tags.GetFirst([]byte("-")) != nil |
||||
if !hasMinusTag { |
||||
return false, "missing NIP-70 `-` tag" |
||||
} |
||||
|
||||
// Check timestamp (must be recent, within +/- 10 minutes)
|
||||
now := time.Now().Unix() |
||||
if ev.CreatedAt < now-600 || ev.CreatedAt > now+600 { |
||||
return false, "timestamp out of range" |
||||
} |
||||
|
||||
return true, "" |
||||
} |
||||
@ -0,0 +1,514 @@
@@ -0,0 +1,514 @@
|
||||
package nip43 |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"next.orly.dev/pkg/crypto/keys" |
||||
"next.orly.dev/pkg/encoders/event" |
||||
"next.orly.dev/pkg/encoders/tag" |
||||
) |
||||
|
||||
// TestInviteManager_GenerateCode tests invite code generation
|
||||
func TestInviteManager_GenerateCode(t *testing.T) { |
||||
im := NewInviteManager(24 * time.Hour) |
||||
|
||||
code, err := im.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate code: %v", err) |
||||
} |
||||
|
||||
if code == "" { |
||||
t.Fatal("generated code is empty") |
||||
} |
||||
|
||||
// Verify the code exists in the manager
|
||||
im.mu.Lock() |
||||
invite, exists := im.codes[code] |
||||
im.mu.Unlock() |
||||
|
||||
if !exists { |
||||
t.Fatal("generated code not found in manager") |
||||
} |
||||
|
||||
if invite.Code != code { |
||||
t.Errorf("code mismatch: got %s, want %s", invite.Code, code) |
||||
} |
||||
|
||||
if invite.UsedBy != nil { |
||||
t.Error("newly generated code should not be used") |
||||
} |
||||
|
||||
if time.Until(invite.ExpiresAt) > 24*time.Hour { |
||||
t.Error("expiry time is too far in the future") |
||||
} |
||||
} |
||||
|
||||
// TestInviteManager_ValidateAndConsume tests invite code validation
|
||||
func TestInviteManager_ValidateAndConsume(t *testing.T) { |
||||
im := NewInviteManager(24 * time.Hour) |
||||
|
||||
// Generate a code
|
||||
code, err := im.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate code: %v", err) |
||||
} |
||||
|
||||
testPubkey := make([]byte, 32) |
||||
for i := range testPubkey { |
||||
testPubkey[i] = byte(i) |
||||
} |
||||
|
||||
// Test valid code
|
||||
valid, reason := im.ValidateAndConsume(code, testPubkey) |
||||
if !valid { |
||||
t.Fatalf("valid code rejected: %s", reason) |
||||
} |
||||
|
||||
// Test already used code
|
||||
valid, reason = im.ValidateAndConsume(code, testPubkey) |
||||
if valid { |
||||
t.Error("already used code was accepted") |
||||
} |
||||
if reason != "invite code already used" { |
||||
t.Errorf("wrong rejection reason: got %s", reason) |
||||
} |
||||
|
||||
// Test invalid code
|
||||
valid, reason = im.ValidateAndConsume("invalid-code", testPubkey) |
||||
if valid { |
||||
t.Error("invalid code was accepted") |
||||
} |
||||
if reason != "invalid invite code" { |
||||
t.Errorf("wrong rejection reason: got %s", reason) |
||||
} |
||||
} |
||||
|
||||
// TestInviteManager_ExpiredCode tests expired invite code handling
|
||||
func TestInviteManager_ExpiredCode(t *testing.T) { |
||||
// Create manager with very short expiry
|
||||
im := NewInviteManager(1 * time.Millisecond) |
||||
|
||||
code, err := im.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate code: %v", err) |
||||
} |
||||
|
||||
// Wait for expiry
|
||||
time.Sleep(10 * time.Millisecond) |
||||
|
||||
testPubkey := make([]byte, 32) |
||||
valid, reason := im.ValidateAndConsume(code, testPubkey) |
||||
if valid { |
||||
t.Error("expired code was accepted") |
||||
} |
||||
if reason != "invite code expired" { |
||||
t.Errorf("wrong rejection reason: got %s, want 'invite code expired'", reason) |
||||
} |
||||
|
||||
// Verify code was deleted
|
||||
im.mu.Lock() |
||||
_, exists := im.codes[code] |
||||
im.mu.Unlock() |
||||
|
||||
if exists { |
||||
t.Error("expired code was not deleted") |
||||
} |
||||
} |
||||
|
||||
// TestInviteManager_CleanupExpired tests cleanup of expired codes
|
||||
func TestInviteManager_CleanupExpired(t *testing.T) { |
||||
im := NewInviteManager(1 * time.Millisecond) |
||||
|
||||
// Generate multiple codes
|
||||
codes := make([]string, 5) |
||||
for i := 0; i < 5; i++ { |
||||
code, err := im.GenerateCode() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate code %d: %v", i, err) |
||||
} |
||||
codes[i] = code |
||||
} |
||||
|
||||
// Wait for expiry
|
||||
time.Sleep(10 * time.Millisecond) |
||||
|
||||
// Cleanup
|
||||
im.CleanupExpired() |
||||
|
||||
// Verify all codes were deleted
|
||||
im.mu.Lock() |
||||
remaining := len(im.codes) |
||||
im.mu.Unlock() |
||||
|
||||
if remaining != 0 { |
||||
t.Errorf("cleanup failed: %d codes remaining", remaining) |
||||
} |
||||
} |
||||
|
||||
// TestBuildMemberListEvent tests membership list event creation
|
||||
func TestBuildMemberListEvent(t *testing.T) { |
||||
// Generate a test relay secret
|
||||
relaySecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate relay secret: %v", err) |
||||
} |
||||
|
||||
// Create test member pubkeys
|
||||
members := make([][]byte, 3) |
||||
for i := 0; i < 3; i++ { |
||||
members[i] = make([]byte, 32) |
||||
for j := range members[i] { |
||||
members[i][j] = byte(i*10 + j) |
||||
} |
||||
} |
||||
|
||||
// Build event
|
||||
ev, err := BuildMemberListEvent(relaySecret, members) |
||||
if err != nil { |
||||
t.Fatalf("failed to build member list event: %v", err) |
||||
} |
||||
|
||||
// Verify event kind
|
||||
if ev.Kind != KindMemberList { |
||||
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindMemberList) |
||||
} |
||||
|
||||
// Verify NIP-70 tag
|
||||
minusTag := ev.Tags.GetFirst([]byte("-")) |
||||
if minusTag == nil { |
||||
t.Error("missing NIP-70 `-` tag") |
||||
} |
||||
|
||||
// Verify member tags
|
||||
memberTags := ev.Tags.GetAll([]byte("member")) |
||||
if len(memberTags) != 3 { |
||||
t.Errorf("wrong number of member tags: got %d, want 3", len(memberTags)) |
||||
} |
||||
|
||||
// Verify signature
|
||||
valid, err := ev.Verify() |
||||
if err != nil { |
||||
t.Fatalf("signature verification error: %v", err) |
||||
} |
||||
if !valid { |
||||
t.Error("event signature is invalid") |
||||
} |
||||
} |
||||
|
||||
// TestBuildAddUserEvent tests add user event creation
|
||||
func TestBuildAddUserEvent(t *testing.T) { |
||||
relaySecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate relay secret: %v", err) |
||||
} |
||||
|
||||
userPubkey := make([]byte, 32) |
||||
for i := range userPubkey { |
||||
userPubkey[i] = byte(i) |
||||
} |
||||
|
||||
ev, err := BuildAddUserEvent(relaySecret, userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to build add user event: %v", err) |
||||
} |
||||
|
||||
// Verify event kind
|
||||
if ev.Kind != KindAddUser { |
||||
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindAddUser) |
||||
} |
||||
|
||||
// Verify NIP-70 tag
|
||||
minusTag := ev.Tags.GetFirst([]byte("-")) |
||||
if minusTag == nil { |
||||
t.Error("missing NIP-70 `-` tag") |
||||
} |
||||
|
||||
// Verify p tag
|
||||
pTag := ev.Tags.GetFirst([]byte("p")) |
||||
if pTag == nil { |
||||
t.Error("missing p tag") |
||||
} |
||||
|
||||
// Verify signature
|
||||
valid, err := ev.Verify() |
||||
if err != nil { |
||||
t.Fatalf("signature verification error: %v", err) |
||||
} |
||||
if !valid { |
||||
t.Error("event signature is invalid") |
||||
} |
||||
} |
||||
|
||||
// TestBuildRemoveUserEvent tests remove user event creation
|
||||
func TestBuildRemoveUserEvent(t *testing.T) { |
||||
relaySecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate relay secret: %v", err) |
||||
} |
||||
|
||||
userPubkey := make([]byte, 32) |
||||
for i := range userPubkey { |
||||
userPubkey[i] = byte(i) |
||||
} |
||||
|
||||
ev, err := BuildRemoveUserEvent(relaySecret, userPubkey) |
||||
if err != nil { |
||||
t.Fatalf("failed to build remove user event: %v", err) |
||||
} |
||||
|
||||
// Verify event kind
|
||||
if ev.Kind != KindRemoveUser { |
||||
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindRemoveUser) |
||||
} |
||||
|
||||
// Verify NIP-70 tag
|
||||
minusTag := ev.Tags.GetFirst([]byte("-")) |
||||
if minusTag == nil { |
||||
t.Error("missing NIP-70 `-` tag") |
||||
} |
||||
|
||||
// Verify p tag
|
||||
pTag := ev.Tags.GetFirst([]byte("p")) |
||||
if pTag == nil { |
||||
t.Error("missing p tag") |
||||
} |
||||
|
||||
// Verify signature
|
||||
valid, err := ev.Verify() |
||||
if err != nil { |
||||
t.Fatalf("signature verification error: %v", err) |
||||
} |
||||
if !valid { |
||||
t.Error("event signature is invalid") |
||||
} |
||||
} |
||||
|
||||
// TestBuildInviteEvent tests invite event creation
|
||||
func TestBuildInviteEvent(t *testing.T) { |
||||
relaySecret, err := keys.GenerateSecretKey() |
||||
if err != nil { |
||||
t.Fatalf("failed to generate relay secret: %v", err) |
||||
} |
||||
|
||||
inviteCode := "test-invite-code-12345" |
||||
|
||||
ev, err := BuildInviteEvent(relaySecret, inviteCode) |
||||
if err != nil { |
||||
t.Fatalf("failed to build invite event: %v", err) |
||||
} |
||||
|
||||
// Verify event kind
|
||||
if ev.Kind != KindInviteReq { |
||||
t.Errorf("wrong event kind: got %d, want %d", ev.Kind, KindInviteReq) |
||||
} |
||||
|
||||
// Verify NIP-70 tag
|
||||
minusTag := ev.Tags.GetFirst([]byte("-")) |
||||
if minusTag == nil { |
||||
t.Error("missing NIP-70 `-` tag") |
||||
} |
||||
|
||||
// Verify claim tag
|
||||
claimTag := ev.Tags.GetFirst([]byte("claim")) |
||||
if claimTag == nil { |
||||
t.Error("missing claim tag") |
||||
} |
||||
if claimTag.Len() < 2 { |
||||
t.Error("claim tag has no value") |
||||
} |
||||
if string(claimTag.T[1]) != inviteCode { |
||||
t.Errorf("wrong invite code in tag: got %s, want %s", string(claimTag.T[1]), inviteCode) |
||||
} |
||||
|
||||
// Verify signature
|
||||
valid, err := ev.Verify() |
||||
if err != nil { |
||||
t.Fatalf("signature verification error: %v", err) |
||||
} |
||||
if !valid { |
||||
t.Error("event signature is invalid") |
||||
} |
||||
} |
||||
|
||||
// TestValidateJoinRequest tests join request validation
|
||||
func TestValidateJoinRequest(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
setupEvent func() *event.E |
||||
expectValid bool |
||||
expectCode string |
||||
expectReason string |
||||
}{ |
||||
{ |
||||
name: "valid join request", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindJoinRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.Tags.Append(tag.NewFromAny("claim", "test-code-123")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
return ev |
||||
}, |
||||
expectValid: true, |
||||
expectCode: "test-code-123", |
||||
expectReason: "", |
||||
}, |
||||
{ |
||||
name: "wrong kind", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = 1000 |
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectReason: "invalid event kind", |
||||
}, |
||||
{ |
||||
name: "missing minus tag", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindJoinRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("claim", "test-code")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectReason: "missing NIP-70 `-` tag", |
||||
}, |
||||
{ |
||||
name: "missing claim tag", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindJoinRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectReason: "missing claim tag", |
||||
}, |
||||
{ |
||||
name: "timestamp too old", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindJoinRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.Tags.Append(tag.NewFromAny("claim", "test-code")) |
||||
ev.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
|
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectCode: "test-code", |
||||
expectReason: "timestamp out of range", |
||||
}, |
||||
{ |
||||
name: "timestamp too far in future", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindJoinRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.Tags.Append(tag.NewFromAny("claim", "test-code")) |
||||
ev.CreatedAt = time.Now().Unix() + 700 // More than 10 minutes ahead
|
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectCode: "test-code", |
||||
expectReason: "timestamp out of range", |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
ev := tt.setupEvent() |
||||
code, valid, reason := ValidateJoinRequest(ev) |
||||
|
||||
if valid != tt.expectValid { |
||||
t.Errorf("valid mismatch: got %v, want %v", valid, tt.expectValid) |
||||
} |
||||
if tt.expectCode != "" && code != tt.expectCode { |
||||
t.Errorf("code mismatch: got %s, want %s", code, tt.expectCode) |
||||
} |
||||
if tt.expectReason != "" && reason != tt.expectReason { |
||||
t.Errorf("reason mismatch: got %s, want %s", reason, tt.expectReason) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestValidateLeaveRequest tests leave request validation
|
||||
func TestValidateLeaveRequest(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
setupEvent func() *event.E |
||||
expectValid bool |
||||
expectReason string |
||||
}{ |
||||
{ |
||||
name: "valid leave request", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindLeaveRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.CreatedAt = time.Now().Unix() |
||||
return ev |
||||
}, |
||||
expectValid: true, |
||||
expectReason: "", |
||||
}, |
||||
{ |
||||
name: "wrong kind", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = 1000 |
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectReason: "invalid event kind", |
||||
}, |
||||
{ |
||||
name: "missing minus tag", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindLeaveRequest |
||||
ev.CreatedAt = time.Now().Unix() |
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectReason: "missing NIP-70 `-` tag", |
||||
}, |
||||
{ |
||||
name: "timestamp out of range", |
||||
setupEvent: func() *event.E { |
||||
ev := event.New() |
||||
ev.Kind = KindLeaveRequest |
||||
ev.Tags = tag.NewS() |
||||
ev.Tags.Append(tag.NewFromAny("-")) |
||||
ev.CreatedAt = time.Now().Unix() - 700 |
||||
return ev |
||||
}, |
||||
expectValid: false, |
||||
expectReason: "timestamp out of range", |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
ev := tt.setupEvent() |
||||
valid, reason := ValidateLeaveRequest(ev) |
||||
|
||||
if valid != tt.expectValid { |
||||
t.Errorf("valid mismatch: got %v, want %v", valid, tt.expectValid) |
||||
} |
||||
if tt.expectReason != "" && reason != tt.expectReason { |
||||
t.Errorf("reason mismatch: got %s, want %s", reason, tt.expectReason) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue