All checks were successful
check / check (push) Successful in 6s
## Summary Adds username and hostname support to sessions, enabling standard IRC hostmask format (`nick!user@host`) for WHOIS, WHO, and future `+b` ban matching. closes #81 ## Changes ### Schema (`001_initial.sql`) - Added `username TEXT NOT NULL DEFAULT ''` and `hostname TEXT NOT NULL DEFAULT ''` columns to the `sessions` table ### Database layer (`internal/db/`) - `CreateSession` now accepts `username` and `hostname` parameters; username defaults to nick if empty - `RegisterUser` now accepts `username` and `hostname` parameters - New `SessionHostInfo` type and `GetSessionHostInfo` query to retrieve username/hostname for a session - `MemberInfo` now includes `Username` and `Hostname` fields - `ChannelMembers` query updated to return username/hostname - New `FormatHostmask(nick, username, hostname)` helper that produces `nick!user@host` format - New `Hostmask()` method on `MemberInfo` ### Handler layer (`internal/handlers/`) - Session creation (`POST /api/v1/session`) accepts optional `username` field; resolves hostname via reverse DNS of connecting client IP (respects `X-Forwarded-For` and `X-Real-IP` headers) - Registration (`POST /api/v1/register`) accepts optional `username` field with the same hostname resolution - Username validation regex: `^[a-zA-Z0-9_\-\[\]\\^{}|` + "\`" + `]{1,32}$` - WHOIS (`311 RPL_WHOISUSER`) now returns the real username and hostname instead of nick/servername - WHO (`352 RPL_WHOREPLY`) now returns the real username and hostname instead of nick/servername - Extracted `validateHashcash` and `resolveUsername` helpers to keep functions under the linter's `funlen` limit - Extracted `executeRegister` helper for the same reason - Reverse DNS uses `(*net.Resolver).LookupAddr` with a 3-second timeout context ### Tests - `TestCreateSessionWithUserHost` — verifies username/hostname are stored and retrievable - `TestCreateSessionDefaultUsername` — verifies empty username defaults to nick - `TestGetSessionHostInfoNotFound` — verifies error on nonexistent session - `TestFormatHostmask` — verifies `nick!user@host` formatting - `TestFormatHostmaskDefaults` — verifies fallback when username/hostname empty - `TestMemberInfoHostmask` — verifies `Hostmask()` method on `MemberInfo` - `TestChannelMembersIncludeUserHost` — verifies `ChannelMembers` returns username/hostname - `TestRegisterUserWithUserHost` — verifies registration stores username/hostname - `TestRegisterUserDefaultUsername` — verifies registration defaults username to nick - `TestWhoisShowsHostInfo` — integration test verifying WHOIS returns the correct username - `TestWhoShowsHostInfo` — integration test verifying WHO returns the correct username - `TestSessionUsernameDefault` — integration test verifying default username in WHOIS - All existing tests updated for new `CreateSession`/`RegisterUser` signatures ### README - New "Hostmask" section documenting the `nick!user@host` format - Updated session creation and registration API docs with the new `username` field - Updated WHOIS/WHO numeric examples to show real username/hostname - Updated sessions schema table with new columns ## Docker build `docker build .` passes cleanly (lint, format, tests, build). Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: clawbot <clawbot@eeqj.de> Reviewed-on: #82 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
319 lines
5.6 KiB
Go
319 lines
5.6 KiB
Go
package db_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func TestRegisterUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
sessionID, clientID, token, err :=
|
|
database.RegisterUser(ctx, "reguser", "password123", "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if sessionID == 0 || clientID == 0 || token == "" {
|
|
t.Fatal("expected valid ids and token")
|
|
}
|
|
|
|
// Verify session works via token lookup.
|
|
sid, cid, nick, err :=
|
|
database.GetSessionByToken(ctx, token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if sid != sessionID || cid != clientID {
|
|
t.Fatal("session/client id mismatch")
|
|
}
|
|
|
|
if nick != "reguser" {
|
|
t.Fatalf("expected reguser, got %s", nick)
|
|
}
|
|
}
|
|
|
|
func TestRegisterUserWithUserHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
sessionID, _, _, err := database.RegisterUser(
|
|
ctx, "reguhost", "password123",
|
|
"myident", "example.org", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := database.GetSessionHostInfo(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if info.Username != "myident" {
|
|
t.Fatalf(
|
|
"expected myident, got %s", info.Username,
|
|
)
|
|
}
|
|
|
|
if info.Hostname != "example.org" {
|
|
t.Fatalf(
|
|
"expected example.org, got %s",
|
|
info.Hostname,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestRegisterUserDefaultUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
sessionID, _, _, err := database.RegisterUser(
|
|
ctx, "regdefault", "password123", "", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := database.GetSessionHostInfo(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if info.Username != "regdefault" {
|
|
t.Fatalf(
|
|
"expected regdefault, got %s",
|
|
info.Username,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestRegisterUserDuplicateNick(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
regSID, regCID, regToken, err :=
|
|
database.RegisterUser(ctx, "dupnick", "password123", "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = regSID
|
|
_ = regCID
|
|
_ = regToken
|
|
|
|
dupSID, dupCID, dupToken, dupErr :=
|
|
database.RegisterUser(ctx, "dupnick", "other12345", "", "", "")
|
|
if dupErr == nil {
|
|
t.Fatal("expected error for duplicate nick")
|
|
}
|
|
|
|
_ = dupSID
|
|
_ = dupCID
|
|
_ = dupToken
|
|
}
|
|
|
|
func TestLoginUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
regSID, regCID, regToken, err :=
|
|
database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = regSID
|
|
_ = regCID
|
|
_ = regToken
|
|
|
|
sessionID, clientID, token, err :=
|
|
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if sessionID == 0 || clientID == 0 || token == "" {
|
|
t.Fatal("expected valid ids and token")
|
|
}
|
|
|
|
// Verify the new token works.
|
|
_, _, nick, err :=
|
|
database.GetSessionByToken(ctx, token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if nick != "loginuser" {
|
|
t.Fatalf("expected loginuser, got %s", nick)
|
|
}
|
|
}
|
|
|
|
func TestLoginUserStoresClientIPHostname(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
regSID, regCID, regToken, err := database.RegisterUser(
|
|
ctx, "loginipuser", "password123",
|
|
"", "", "10.0.0.1",
|
|
)
|
|
|
|
_ = regSID
|
|
_ = regCID
|
|
_ = regToken
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, clientID, _, err := database.LoginUser(
|
|
ctx, "loginipuser", "password123",
|
|
"10.0.0.99", "newhost.example.com",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
clientInfo, err := database.GetClientHostInfo(
|
|
ctx, clientID,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if clientInfo.IP != "10.0.0.99" {
|
|
t.Fatalf(
|
|
"expected client IP 10.0.0.99, got %s",
|
|
clientInfo.IP,
|
|
)
|
|
}
|
|
|
|
if clientInfo.Hostname != "newhost.example.com" {
|
|
t.Fatalf(
|
|
"expected hostname newhost.example.com, got %s",
|
|
clientInfo.Hostname,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestRegisterUserStoresSessionIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
sessionID, _, _, err := database.RegisterUser(
|
|
ctx, "regipuser", "password123",
|
|
"ident", "host.local", "172.16.0.5",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := database.GetSessionHostInfo(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if info.IP != "172.16.0.5" {
|
|
t.Fatalf(
|
|
"expected session IP 172.16.0.5, got %s",
|
|
info.IP,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLoginUserWrongPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
regSID, regCID, regToken, err :=
|
|
database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = regSID
|
|
_ = regCID
|
|
_ = regToken
|
|
|
|
loginSID, loginCID, loginToken, loginErr :=
|
|
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
|
if loginErr == nil {
|
|
t.Fatal("expected error for wrong password")
|
|
}
|
|
|
|
_ = loginSID
|
|
_ = loginCID
|
|
_ = loginToken
|
|
}
|
|
|
|
func TestLoginUserNoPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
// Create anonymous session (no password).
|
|
anonSID, anonCID, anonToken, err :=
|
|
database.CreateSession(ctx, "anon", "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = anonSID
|
|
_ = anonCID
|
|
_ = anonToken
|
|
|
|
loginSID, loginCID, loginToken, loginErr :=
|
|
database.LoginUser(ctx, "anon", "anything1", "", "")
|
|
if loginErr == nil {
|
|
t.Fatal(
|
|
"expected error for login on passwordless account",
|
|
)
|
|
}
|
|
|
|
_ = loginSID
|
|
_ = loginCID
|
|
_ = loginToken
|
|
}
|
|
|
|
func TestLoginUserNonexistent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database := setupTestDB(t)
|
|
ctx := t.Context()
|
|
|
|
loginSID, loginCID, loginToken, err :=
|
|
database.LoginUser(ctx, "ghost", "password123", "", "")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent user")
|
|
}
|
|
|
|
_ = loginSID
|
|
_ = loginCID
|
|
_ = loginToken
|
|
}
|