diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index ddfceffe0..2af537d69 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -66,7 +66,7 @@ type Session struct { data *SessionData `gorm:"-" yaml:"-"` RefID string `gorm:"type:VARBINARY(16);default:'';" json:"ID" yaml:"-"` LoginIP string `gorm:"size:64;column:login_ip" json:"LoginIP" yaml:"-"` - LoginAt time.Time `json:"LoginAt" yaml:"-"` + LoginAt *time.Time `json:"LoginAt" yaml:"-"` CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt"` Status int `gorm:"-" json:"Status" yaml:"-"` @@ -1053,7 +1053,7 @@ func (m *Session) SetClientIP(ip string) { if m.LoginIP == "" { m.LoginIP = ip - m.LoginAt = Now() + m.LoginAt = TimeStamp() } return diff --git a/internal/entity/auth_session_client_test.go b/internal/entity/auth_session_client_test.go index 5190755a3..504bd5156 100644 --- a/internal/entity/auth_session_client_test.go +++ b/internal/entity/auth_session_client_test.go @@ -95,4 +95,47 @@ func TestAddClientSession(t *testing.T) { t.Logf("sess: %#v", sess) }) + t.Run("NoClientIPPersistsNullLoginAt", func(t *testing.T) { + // Sessions created without a client IP (e.g. via "photoprism auth add") + // must persist login_at as SQL NULL, not a zero "0000-00-00" datetime + // that strict MySQL/MariaDB sql_modes reject with Error 1292. + // A value-typed LoginAt leaves Go's zero time.Time, which go-sql-driver/mysql serializes as the literal 0000-00-00. + sess, err := AddClientSession("", unix.Day, "metrics", authn.GrantCLI, nil) + + assert.NoError(t, err) + + if sess == nil { + t.Fatal("session must not be nil") + } + + assert.Nil(t, sess.LoginAt) + + var nullCount int + if err = UnscopedDb().Table("auth_sessions"). + Where("id = ? AND login_at IS NULL", sess.ID). + Count(&nullCount).Error; err != nil { + t.Fatal(err) + } + + assert.Equal(t, 1, nullCount, "login_at must be NULL when no client IP was set") + }) + t.Run("ClientIPSetsLoginAt", func(t *testing.T) { + sess := NewClientSession("", unix.Day, "metrics", authn.GrantClientCredentials, nil) + sess.SetClientIP("203.0.113.7") + + assert.NoError(t, sess.Create()) + + if sess.LoginAt == nil { + t.Fatal("login_at must be set when a client IP is present") + } + + var nullCount int + if err := UnscopedDb().Table("auth_sessions"). + Where("id = ? AND login_at IS NULL", sess.ID). + Count(&nullCount).Error; err != nil { + t.Fatal(err) + } + + assert.Equal(t, 0, nullCount, "login_at must not be NULL when a client IP was set") + }) } diff --git a/internal/entity/auth_session_fixtures.go b/internal/entity/auth_session_fixtures.go index 887b35a4b..fbf434277 100644 --- a/internal/entity/auth_session_fixtures.go +++ b/internal/entity/auth_session_fixtures.go @@ -129,7 +129,7 @@ var SessionFixtures = SessionMap{ user: UserFixtures.Pointer("bob"), UserUID: UserFixtures.Pointer("bob").UserUID, UserName: UserFixtures.Pointer("bob").UserName, - LoginAt: Now().Add(-24), + LoginAt: TimePointer(Now().Add(-24)), CreatedAt: Now().Add(-24), UpdatedAt: Now().Add(-24), }, @@ -184,7 +184,7 @@ var SessionFixtures = SessionMap{ user: UserFixtures.Pointer("friend"), UserUID: UserFixtures.Pointer("friend").UserUID, UserName: UserFixtures.Pointer("friend").UserName, - LoginAt: Now().Add(-12), + LoginAt: TimePointer(Now().Add(-12)), CreatedAt: Now().Add(-20), UpdatedAt: Now().Add(-12), }, diff --git a/internal/entity/entity_time.go b/internal/entity/entity_time.go index 77df064d5..984b9c224 100644 --- a/internal/entity/entity_time.go +++ b/internal/entity/entity_time.go @@ -23,6 +23,15 @@ func TimeStamp() *time.Time { return &t } +// TimePointer returns a reference to the specified time, or nil if it is the zero value. +func TimePointer(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + + return &t +} + // Time returns a reference to the specified time in UTC, truncated to seconds, or nil if it is invalid. func Time(s string) *time.Time { if t, err := time.Parse(time.RFC3339, s); err == nil { diff --git a/internal/entity/entity_time_test.go b/internal/entity/entity_time_test.go index 5349449b6..915923009 100644 --- a/internal/entity/entity_time_test.go +++ b/internal/entity/entity_time_test.go @@ -137,6 +137,20 @@ func TestTimeStamp(t *testing.T) { } } +func TestTimePointer(t *testing.T) { + t.Run("NonZero", func(t *testing.T) { + now := Now() + result := TimePointer(now) + if result == nil { + t.Fatal("result must not be nil") + } + assert.Equal(t, now, *result) + }) + t.Run("Zero", func(t *testing.T) { + assert.Nil(t, TimePointer(time.Time{})) + }) +} + func TestTime(t *testing.T) { result := Time("2022-01-02T13:04:05+01:00")