package membership import ( "encoding/hex" "encoding/json" "errors" "testing" "time" cs "fn-registry/functions/cybersecurity" ) // newIDHex generates a fresh identity and returns its signing and key-exchange // public keys as lowercase hex — the two keys a client presents to /register. func newIDHex(t *testing.T) (signPub, kexPub string) { t.Helper() id, err := cs.GenerateIdentity() if err != nil { t.Fatalf("identity: %v", err) } return hex.EncodeToString(id.SignPub), hex.EncodeToString(id.KexPub) } // inviteSuite drives the full invite lifecycle against any Store backend: mint, // look up, redeem (which registers the user), reject a second redeem (single-use) // and a non-existent token, reject an expired token (forced past via the // backend-specific forceExpire closure), and hard-delete a user. It is shared by // the SQLite and JetStream tests so both backends prove identical behavior. func inviteSuite(t *testing.T, s Store, forceExpire func(token string)) { t.Helper() // Mint an invite fixing handle + role. inv, err := s.CreateInvite("alice-new", RoleMember, 3600) if err != nil { t.Fatalf("CreateInvite: %v", err) } if len(inv.Token) != 64 { t.Fatalf("token should be 64 hex chars, got %d (%q)", len(inv.Token), inv.Token) } if inv.Used { t.Fatalf("fresh invite must not be used") } // GetInvite round-trips it. got, err := s.GetInvite(inv.Token) if err != nil || got.Handle != "alice-new" || got.Role != RoleMember { t.Fatalf("GetInvite mismatch: %+v err=%v", got, err) } // Redeem it: the presented signing key joins the allowlist with the invite's // handle and role. signPub, kexPub := newIDHex(t) if err := s.ConsumeInvite(inv.Token, signPub, kexPub); err != nil { t.Fatalf("ConsumeInvite (golden): %v", err) } u, err := s.GetUser(signPub) if err != nil { t.Fatalf("GetUser after register: %v", err) } if u.Handle != "alice-new" || u.Role != RoleMember || u.Status != StatusActive { t.Fatalf("registered user wrong: %+v", u) } if !s.IsAuthorized(signPub) { t.Fatalf("registered user should be authorized") } // Single-use: redeeming the same token again (even with a different identity) // is rejected as used. sp2, kp2 := newIDHex(t) if err := s.ConsumeInvite(inv.Token, sp2, kp2); !errors.Is(err, ErrInviteUsed) { t.Fatalf("second redeem should be ErrInviteUsed, got %v", err) } if _, err := s.GetUser(sp2); !errors.Is(err, ErrNotFound) { t.Fatalf("second identity must NOT be registered, got %v", err) } // Unknown token is ErrNotFound. if err := s.ConsumeInvite("deadbeef", "ab", "cd"); !errors.Is(err, ErrNotFound) { t.Fatalf("unknown token should be ErrNotFound, got %v", err) } // Expired invite: mint one, force its deadline into the past, redeem -> rejected. exp, err := s.CreateInvite("late", RoleMember, 3600) if err != nil { t.Fatalf("CreateInvite expired: %v", err) } forceExpire(exp.Token) sp3, kp3 := newIDHex(t) if err := s.ConsumeInvite(exp.Token, sp3, kp3); !errors.Is(err, ErrInviteExpired) { t.Fatalf("expired redeem should be ErrInviteExpired, got %v", err) } // CancelInvite removes a pending invite; redeeming it afterward is ErrNotFound. canc, err := s.CreateInvite("cancelme", RoleMember, 3600) if err != nil { t.Fatalf("CreateInvite cancel: %v", err) } if err := s.CancelInvite(canc.Token); err != nil { t.Fatalf("CancelInvite: %v", err) } if err := s.ConsumeInvite(canc.Token, sp3, kp3); !errors.Is(err, ErrNotFound) { t.Fatalf("cancelled invite redeem should be ErrNotFound, got %v", err) } // Hard-delete the registered user: it disappears from the allowlist entirely. if err := s.DeleteUser(signPub); err != nil { t.Fatalf("DeleteUser: %v", err) } if _, err := s.GetUser(signPub); !errors.Is(err, ErrNotFound) { t.Fatalf("deleted user should be ErrNotFound, got %v", err) } if s.IsAuthorized(signPub) { t.Fatalf("deleted user must not be authorized") } // Deleting an unknown key is ErrNotFound. if err := s.DeleteUser(signPub); !errors.Is(err, ErrNotFound) { t.Fatalf("re-delete should be ErrNotFound, got %v", err) } } // TestInvitesSQLite runs the suite against the default SQLite backend, forcing // expiry with a direct UPDATE on the embedded DB (white-box, same package). func TestInvitesSQLite(t *testing.T) { s := openTestStore(t) inviteSuite(t, s, func(token string) { past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano) if _, err := s.db.Exec(`UPDATE invites SET expires_at = ? WHERE token = ?`, past, token); err != nil { t.Fatalf("force expire: %v", err) } }) } // TestInvitesJetStream runs the same suite against the replicated KV backend, // forcing expiry by re-Putting the invite JSON with a past deadline. func TestInvitesJetStream(t *testing.T) { s, _, _ := newKVStore(t) inviteSuite(t, s, func(token string) { inv, err := s.GetInvite(token) if err != nil { t.Fatalf("force expire: get invite: %v", err) } inv.ExpiresAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano) b, err := json.Marshal(inv) if err != nil { t.Fatalf("force expire: marshal: %v", err) } ctx, cancel := s.ctx() defer cancel() if _, err := s.invites.Put(ctx, token, b); err != nil { t.Fatalf("force expire: put: %v", err) } }) } // TestConsumeInvite_AlreadyRegistered covers the burn-on-claim edge: redeeming a // valid invite with a signing key that is already registered surfaces // ErrUserExists AND spends the invite (both backends behave identically). func TestConsumeInvite_AlreadyRegistered(t *testing.T) { for _, tc := range []struct { name string open func(t *testing.T) Store }{ {"sqlite", func(t *testing.T) Store { return openTestStore(t) }}, {"jetstream", func(t *testing.T) Store { s, _, _ := newKVStore(t); return s }}, } { t.Run(tc.name, func(t *testing.T) { s := tc.open(t) signPub, kexPub := newIDHex(t) if err := s.AddUser(signPub, "existing", RoleMember); err != nil { t.Fatalf("seed user: %v", err) } inv, err := s.CreateInvite("dup", RoleMember, 3600) if err != nil { t.Fatalf("CreateInvite: %v", err) } if err := s.ConsumeInvite(inv.Token, signPub, kexPub); !errors.Is(err, ErrUserExists) { t.Fatalf("redeem with registered key should be ErrUserExists, got %v", err) } // The invite is spent (burn-on-claim): a fresh identity cannot reuse it. sp2, kp2 := newIDHex(t) if err := s.ConsumeInvite(inv.Token, sp2, kp2); !errors.Is(err, ErrInviteUsed) { t.Fatalf("invite should be spent after a burned claim, got %v", err) } }) } }