feat: add username/hostname support with IRC hostmask format
All checks were successful
check / check (push) Successful in 2m11s

- Add username and hostname columns to sessions table (001_initial.sql)
- Accept optional username field in session creation and registration
  endpoints; defaults to nick if not provided
- Resolve hostname via reverse DNS of connecting client IP at session
  creation time (supports X-Forwarded-For and X-Real-IP headers)
- Display real username and hostname in WHOIS (311 RPL_WHOISUSER) and
  WHO (352 RPL_WHOREPLY) responses instead of nick/servername
- Add FormatHostmask helper for nick!user@host format
- Add SessionHostInfo type and GetSessionHostInfo query
- Include username/hostname in MemberInfo and ChannelMembers results
- Extract validateHashcash and resolveUsername helpers to stay under
  funlen limits
- Add comprehensive unit tests for all new DB functions, hostmask
  formatting, and integration tests for WHOIS/WHO responses
- Update README with hostmask documentation, new API fields, and
  updated schema reference
This commit is contained in:
user
2026-03-17 05:34:57 -07:00
parent e36bd99ef6
commit e42c6c1868
9 changed files with 804 additions and 65 deletions

View File

@@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) {
ctx := t.Context()
sessionID, _, token, err := database.CreateSession(
ctx, "alice",
ctx, "alice", "", "",
)
if err != nil {
t.Fatal(err)
@@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) {
}
_, _, dupToken, dupErr := database.CreateSession(
ctx, "alice",
ctx, "alice", "", "",
)
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
@@ -54,13 +54,186 @@ func TestCreateSession(t *testing.T) {
_ = dupToken
}
// assertSessionHostInfo creates a session and verifies
// the stored username and hostname match expectations.
func assertSessionHostInfo(
t *testing.T,
database *db.Database,
nick, inputUser, inputHost,
expectUser, expectHost string,
) {
t.Helper()
sessionID, _, _, err := database.CreateSession(
t.Context(), nick, inputUser, inputHost,
)
if err != nil {
t.Fatal(err)
}
info, err := database.GetSessionHostInfo(
t.Context(), sessionID,
)
if err != nil {
t.Fatal(err)
}
if info.Username != expectUser {
t.Fatalf(
"expected username %s, got %s",
expectUser, info.Username,
)
}
if info.Hostname != expectHost {
t.Fatalf(
"expected hostname %s, got %s",
expectHost, info.Hostname,
)
}
}
func TestCreateSessionWithUserHost(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
assertSessionHostInfo(
t, database,
"hostuser", "myident", "example.com",
"myident", "example.com",
)
}
func TestCreateSessionDefaultUsername(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
// Empty username defaults to nick.
assertSessionHostInfo(
t, database,
"defaultu", "", "host.local",
"defaultu", "host.local",
)
}
func TestGetSessionHostInfoNotFound(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
_, err := database.GetSessionHostInfo(
t.Context(), 99999,
)
if err == nil {
t.Fatal("expected error for nonexistent session")
}
}
func TestFormatHostmask(t *testing.T) {
t.Parallel()
result := db.FormatHostmask(
"nick", "user", "host.com",
)
if result != "nick!user@host.com" {
t.Fatalf(
"expected nick!user@host.com, got %s",
result,
)
}
}
func TestFormatHostmaskDefaults(t *testing.T) {
t.Parallel()
result := db.FormatHostmask("nick", "", "")
if result != "nick!nick@*" {
t.Fatalf(
"expected nick!nick@*, got %s",
result,
)
}
}
func TestMemberInfoHostmask(t *testing.T) {
t.Parallel()
member := &db.MemberInfo{ //nolint:exhaustruct // test only uses hostmask fields
Nick: "alice",
Username: "aliceident",
Hostname: "alice.example.com",
}
hostmask := member.Hostmask()
expected := "alice!aliceident@alice.example.com"
if hostmask != expected {
t.Fatalf(
"expected %s, got %s", expected, hostmask,
)
}
}
func TestChannelMembersIncludeUserHost(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, _, _, err := database.CreateSession(
ctx, "memuser", "myuser", "myhost.net",
)
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(
ctx, "#hostchan",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
members, err := database.ChannelMembers(ctx, chID)
if err != nil {
t.Fatal(err)
}
if len(members) != 1 {
t.Fatalf(
"expected 1 member, got %d", len(members),
)
}
if members[0].Username != "myuser" {
t.Fatalf(
"expected username myuser, got %s",
members[0].Username,
)
}
if members[0].Hostname != "myhost.net" {
t.Fatalf(
"expected hostname myhost.net, got %s",
members[0].Hostname,
)
}
}
func TestGetSessionByToken(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, _, token, err := database.CreateSession(ctx, "bob")
_, _, token, err := database.CreateSession(ctx, "bob", "", "")
if err != nil {
t.Fatal(err)
}
@@ -93,7 +266,7 @@ func TestGetSessionByNick(t *testing.T) {
ctx := t.Context()
charlieID, charlieClientID, charlieToken, err :=
database.CreateSession(ctx, "charlie")
database.CreateSession(ctx, "charlie", "", "")
if err != nil {
t.Fatal(err)
}
@@ -150,7 +323,7 @@ func TestJoinAndPart(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sid, _, _, err := database.CreateSession(ctx, "user1")
sid, _, _, err := database.CreateSession(ctx, "user1", "", "")
if err != nil {
t.Fatal(err)
}
@@ -199,7 +372,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err)
}
sid, _, _, err := database.CreateSession(ctx, "temp")
sid, _, _, err := database.CreateSession(ctx, "temp", "", "")
if err != nil {
t.Fatal(err)
}
@@ -234,7 +407,7 @@ func createSessionWithChannels(
ctx := t.Context()
sid, _, _, err := database.CreateSession(ctx, nick)
sid, _, _, err := database.CreateSession(ctx, nick, "", "")
if err != nil {
t.Fatal(err)
}
@@ -317,7 +490,7 @@ func TestChangeNick(t *testing.T) {
ctx := t.Context()
sid, _, token, err := database.CreateSession(
ctx, "old",
ctx, "old", "", "",
)
if err != nil {
t.Fatal(err)
@@ -401,7 +574,7 @@ func TestPollMessages(t *testing.T) {
ctx := t.Context()
sid, _, token, err := database.CreateSession(
ctx, "poller",
ctx, "poller", "", "",
)
if err != nil {
t.Fatal(err)
@@ -508,7 +681,7 @@ func TestDeleteSession(t *testing.T) {
ctx := t.Context()
sid, _, _, err := database.CreateSession(
ctx, "deleteme",
ctx, "deleteme", "", "",
)
if err != nil {
t.Fatal(err)
@@ -548,12 +721,12 @@ func TestChannelMembers(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sid1, _, _, err := database.CreateSession(ctx, "m1")
sid1, _, _, err := database.CreateSession(ctx, "m1", "", "")
if err != nil {
t.Fatal(err)
}
sid2, _, _, err := database.CreateSession(ctx, "m2")
sid2, _, _, err := database.CreateSession(ctx, "m2", "", "")
if err != nil {
t.Fatal(err)
}
@@ -611,7 +784,7 @@ func TestEnqueueToClient(t *testing.T) {
ctx := t.Context()
_, _, token, err := database.CreateSession(
ctx, "enqclient",
ctx, "enqclient", "", "",
)
if err != nil {
t.Fatal(err)