package webapp import ( "context" "database/sql" "errors" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newTestStore(t *testing.T) *Store { t.Helper() // Use a per-test in-memory DSN so the schema is fresh and there // are no goroutine-leak concerns from shared cache. store, err := OpenStore(":memory:") require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) return store } func TestStoreCreateAndGetUser(t *testing.T) { s := newTestStore(t) ctx := context.Background() _, err := s.GetUser(ctx) require.ErrorIs(t, err, sql.ErrNoRows, "fresh store must have no user") hash, err := HashPassword("hello world") require.NoError(t, err) u, err := s.CreateUser(ctx, hash, false) require.NoError(t, err) require.NotNil(t, u) assert.NotZero(t, u.ID) assert.False(t, u.RequiresChange, "fresh user must not require change") got, err := s.GetUser(ctx) require.NoError(t, err) assert.Equal(t, u.ID, got.ID) assert.Equal(t, hash, got.BcryptHash) } func TestStoreUpdatePasswordMarksRequiresChange(t *testing.T) { s := newTestStore(t) ctx := context.Background() hash, err := HashPassword("first") require.NoError(t, err) u, err := s.CreateUser(ctx, hash, false) require.NoError(t, err) newHash, err := HashPassword("second") require.NoError(t, err) require.NoError(t, s.UpdatePassword(ctx, u.ID, newHash)) got, err := s.GetUser(ctx) require.NoError(t, err) assert.Equal(t, newHash, got.BcryptHash) assert.False(t, got.RequiresChange) require.NoError(t, s.MarkRequiresChange(ctx, u.ID)) got, err = s.GetUser(ctx) require.NoError(t, err) assert.True(t, got.RequiresChange) } func TestStoreSessionLifecycle(t *testing.T) { s := newTestStore(t) ctx := context.Background() hash, err := HashPassword("p") require.NoError(t, err) u, err := s.CreateUser(ctx, hash, false) require.NoError(t, err) sess := Session{ ID: "sess-1", UserID: u.ID, CSRFToken: "csrf-1", IP: "127.0.0.1", UA: "ua", ExpiresAt: time.Now().Add(time.Minute).UTC(), } require.NoError(t, s.CreateSession(ctx, &sess)) got, err := s.GetSession(ctx, "sess-1") require.NoError(t, err) assert.Equal(t, sess.CSRFToken, got.CSRFToken) assert.Equal(t, u.ID, got.UserID) require.NoError(t, s.TouchSession(ctx, "sess-1", 5*time.Minute, 8*time.Hour)) require.NoError(t, s.DeleteSession(ctx, "sess-1")) _, err = s.GetSession(ctx, "sess-1") assert.ErrorIs(t, err, sql.ErrNoRows) } func TestStoreGetSessionExpired(t *testing.T) { s := newTestStore(t) ctx := context.Background() hash, err := HashPassword("p") require.NoError(t, err) u, err := s.CreateUser(ctx, hash, false) require.NoError(t, err) sess := Session{ ID: "sess-2", UserID: u.ID, CSRFToken: "csrf-2", ExpiresAt: time.Now().Add(-time.Second).UTC(), } require.NoError(t, s.CreateSession(ctx, &sess)) _, err = s.GetSession(ctx, "sess-2") assert.ErrorIs(t, err, sql.ErrNoRows, "expired session must surface as no-rows") } func TestStoreCreateSessionValidation(t *testing.T) { s := newTestStore(t) ctx := context.Background() cases := []struct { name string sess Session }{ { "empty id", Session{CSRFToken: "c", UserID: 1, ExpiresAt: time.Now().Add(time.Minute)}, }, { "empty csrf", Session{ID: "x", UserID: 1, ExpiresAt: time.Now().Add(time.Minute)}, }, { "empty expiry", Session{ID: "x", UserID: 1, CSRFToken: "c"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := s.CreateSession(ctx, &tc.sess) assert.Error(t, err) }) } } func TestStoreAuditWriteAndRecent(t *testing.T) { s := newTestStore(t) ctx := context.Background() for i := 0; i < 5; i++ { require.NoError(t, s.WriteAudit(ctx, &AuditEntry{ Actor: "operator", Role: "admin", AuthMode: "local", Action: "login", Target: "self", BeforeHash: "", AfterHash: "", })) } got, err := s.RecentAudit(ctx, 3) require.NoError(t, err) assert.Len(t, got, 3, "RecentAudit must respect limit") // Empty action must be rejected. err = s.WriteAudit(ctx, &AuditEntry{Action: ""}) assert.Error(t, err) } func TestStoreAuditPrune(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC() // 10 rows older than 1h and 5 rows from "now". for i := 0; i < 10; i++ { require.NoError(t, s.WriteAudit(ctx, &AuditEntry{ Action: "old", TS: now.Add(-2 * time.Hour), })) } for i := 0; i < 5; i++ { require.NoError(t, s.WriteAudit(ctx, &AuditEntry{ Action: "fresh", TS: now, })) } n, err := s.PruneAudit(ctx, time.Hour) require.NoError(t, err) assert.Equal(t, int64(10), n, "expected to prune exactly the 10 old rows") got, err := s.RecentAudit(ctx, 100) require.NoError(t, err) require.Len(t, got, 5) for _, e := range got { assert.Equal(t, "fresh", e.Action, "only fresh rows must remain") } } func TestStoreReplaceAndListApps(t *testing.T) { s := newTestStore(t) ctx := context.Background() first := []App{ {Name: "rsmon-worker", Source: "process", PID: 100, Ports: "7401/tcp", StartTS: 1, LastSeen: time.Now().UTC(), JSONBlob: `{"foo":"bar"}`}, {Name: "postgres", Source: "process", PID: 200, Ports: "5432/tcp", StartTS: 2, LastSeen: time.Now().UTC(), JSONBlob: ""}, } require.NoError(t, s.ReplaceApps(ctx, first)) list, err := s.ListApps(ctx) require.NoError(t, err) require.Len(t, list, 2) assert.Equal(t, "rsmon-worker", list[0].Name) assert.True(t, strings.HasPrefix(list[0].JSONBlob, "{"), "JSON blob must round-trip") second := []App{ {Name: "redis", Source: "process", PID: 300, Ports: "6379/tcp", StartTS: 3, LastSeen: time.Now().UTC()}, } require.NoError(t, s.ReplaceApps(ctx, second)) list, err = s.ListApps(ctx) require.NoError(t, err) require.Len(t, list, 1) assert.Equal(t, "redis", list[0].Name) } func TestStoreOpenInvalidPath(t *testing.T) { _, err := OpenStore("") assert.True(t, errors.Is(err, err) || err != nil) }