package store import ( "context" "testing" "github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/auth" ) // The in-memory fake agrees with whatever SQL I wrote. These run the two new // statements against Postgres: the RETURNING list has to scan, the tenant // scope has to hold, and a reset has to actually revoke the sessions row. func TestLiveAManagerCreatedLoginRoundTrips(t *testing.T) { st := liveStore(t) ctx := context.Background() clientID, _ := seedTenant(t, st, "mem"+stamp(), 0, false) hash, err := auth.HashPassword("a-perfectly-good-password") if err != nil { t.Fatal(err) } m, err := st.CreateMember(ctx, clientID, api.NewMemberInput{ Email: "priya@" + stamp() + ".test", FullName: "Priya R", Role: "staff", }, hash) if err != nil { t.Fatalf("create: %v", err) } if m.ID == "" || !m.Active || m.Role != "staff" || m.CreatedAt == "" { t.Fatalf("member not as created: %+v", m) } // LastLoginAt is RETURNED as '' for a brand-new row; it must scan into a // string, not fail as an untyped literal. if m.LastLoginAt != "" { t.Fatalf("a new member has never logged in, got %q", m.LastLoginAt) } // Findable by the login path, in the right tenant, with the hash intact. rec, err := st.UserByEmail(ctx, m.Email) if err != nil || !rec.Found { t.Fatalf("new member not findable: %v found=%v", err, rec.Found) } if rec.ClientID != clientID || !auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password") { t.Fatalf("landed wrong: client=%s verify=%v", rec.ClientID, auth.VerifyPassword(rec.PasswordHash, "a-perfectly-good-password")) } } func TestLiveAResetIsTenantScopedAndRevokesSessions(t *testing.T) { st := liveStore(t) ctx := context.Background() mine, _ := seedTenant(t, st, "rsa"+stamp(), 0, false) theirs, _ := seedTenant(t, st, "rsb"+stamp(), 0, false) oldHash, _ := auth.HashPassword("old-password-here") m, err := st.CreateMember(ctx, mine, api.NewMemberInput{ Email: "sam@" + stamp() + ".test", FullName: "Sam", Role: "staff"}, oldHash) if err != nil { t.Fatalf("create: %v", err) } // Give them a live session to lose. if _, err := st.pool.Exec(ctx, ` INSERT INTO sessions (user_id, client_id, access_hash, refresh_hash, access_expires_at, refresh_expires_at, device) VALUES ($1::uuid, $2::uuid, $3, $4, now() + interval '1 hour', now() + interval '30 days', 'lost phone')`, m.ID, mine, []byte("a"+stamp()), []byte("r"+stamp())); err != nil { t.Fatalf("seed session: %v", err) } // Another tenant's manager cannot reset them, and it reads as no such row. newHash, _ := auth.HashPassword("new-password-here") if _, err := st.ResetMemberPassword(ctx, theirs, m.ID, newHash); err == nil { t.Fatal("a reset from another tenant should find nobody") } // Their own tenant can, and it takes the session with it. if _, err := st.ResetMemberPassword(ctx, mine, m.ID, newHash); err != nil { t.Fatalf("reset: %v", err) } var live int if err := st.pool.QueryRow(ctx, ` SELECT count(*) FROM sessions WHERE user_id = $1::uuid AND revoked_at IS NULL`, m.ID).Scan(&live); err != nil { t.Fatal(err) } if live != 0 { t.Fatalf("%d session(s) survived a password reset", live) } rec, _ := st.UserByEmail(ctx, m.Email) if !auth.VerifyPassword(rec.PasswordHash, "new-password-here") || auth.VerifyPassword(rec.PasswordHash, "old-password-here") { t.Fatal("the hash did not change to the new password") } }