package user import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // fakeRepo is an in-memory Repository implementation used by the service // unit tests. It deliberately stores users keyed by email and sessions keyed // by the string form of TokenHash so lookups stay O(1) and the test setup // doesn't need to reach into pgx-specific machinery. type fakeRepo struct { users map[string]*User sessions map[string]*Session } func newFakeRepo() *fakeRepo { return &fakeRepo{users: map[string]*User{}, sessions: map[string]*Session{}} } func (r *fakeRepo) CreateUser(_ context.Context, u *User) (*User, error) { r.users[u.Email] = u return u, nil } func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*User, error) { u, ok := r.users[email] if !ok { return nil, errs.New(errs.CodeNotFound, "no user") } return u, nil } func (r *fakeRepo) GetUserByID(_ context.Context, id uuid.UUID) (*User, error) { for _, u := range r.users { if u.ID == id { return u, nil } } return nil, errs.New(errs.CodeNotFound, "no user") } func (r *fakeRepo) CountUsers(_ context.Context) (int, error) { return len(r.users), nil } func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) { return nil, nil } func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error { r.sessions[string(s.TokenHash)] = s return nil } func (r *fakeRepo) GetSessionByTokenHash(_ context.Context, h []byte) (*Session, error) { s, ok := r.sessions[string(h)] if !ok || s.ExpiresAt.Before(time.Now()) { return nil, errs.New(errs.CodeUnauthorized, "no session") } return s, nil } func (r *fakeRepo) TouchSession(_ context.Context, _ []byte) error { return nil } func (r *fakeRepo) DeleteSessionByTokenHash(_ context.Context, h []byte) error { delete(r.sessions, string(h)) return nil } // newServiceWithUser wires a *service backed by a fakeRepo that already // contains a single user with the given credentials. The returned *service // is the concrete type (not the Service interface) so individual tests can // poke at internal hooks like `now` if a future case needs it. func newServiceWithUser(t *testing.T, email, password string) (*service, *User) { t.Helper() repo := newFakeRepo() hash, err := HashPassword(password) require.NoError(t, err) u := &User{ID: uuid.New(), Email: email, DisplayName: "Test", PasswordHash: hash} repo.users[email] = u svc := NewService(repo, nil).(*service) return svc, u } func TestLogin_Success(t *testing.T) { svc, u := newServiceWithUser(t, "a@b.c", "password123") tok, gotUser, err := svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1") require.NoError(t, err) require.NotEmpty(t, tok) require.Equal(t, u.ID, gotUser.ID) } func TestLogin_BadPassword(t *testing.T) { svc, _ := newServiceWithUser(t, "a@b.c", "password123") _, _, err := svc.Login(context.Background(), "a@b.c", "wrong-pass", "ip") require.Error(t, err) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeUnauthorized, ae.Code) } func TestLogin_UnknownEmail(t *testing.T) { svc, _ := newServiceWithUser(t, "a@b.c", "password123") _, _, err := svc.Login(context.Background(), "x@b.c", "x", "ip") require.Error(t, err) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeUnauthorized, ae.Code, "should not leak whether email exists") } func TestResolveSession_Success(t *testing.T) { svc, u := newServiceWithUser(t, "a@b.c", "password123") tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "ip") require.NoError(t, err) uid, ok, err := svc.ResolveSession(context.Background(), tok) require.NoError(t, err) require.True(t, ok) require.Equal(t, u.ID, uid) } func TestLogout_RemovesSession(t *testing.T) { svc, _ := newServiceWithUser(t, "a@b.c", "password123") tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "ip") require.NoError(t, err) require.NoError(t, svc.Logout(context.Background(), tok)) _, ok, err := svc.ResolveSession(context.Background(), tok) require.NoError(t, err) require.False(t, ok) } type spyAudit struct{ entries []audit.Entry } func (s *spyAudit) Record(_ context.Context, e audit.Entry) error { s.entries = append(s.entries, e) return nil } func TestLogin_RecordsAudit(t *testing.T) { repo := newFakeRepo() hash, err := HashPassword("password123") require.NoError(t, err) repo.users["a@b.c"] = &User{ID: uuid.New(), Email: "a@b.c", DisplayName: "T", PasswordHash: hash} spy := &spyAudit{} svc := NewService(repo, spy) _, _, err = svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1") require.NoError(t, err) require.Len(t, spy.entries, 1) require.Equal(t, "user.login", spy.entries[0].Action) require.Equal(t, "127.0.0.1", spy.entries[0].IP) require.NotNil(t, spy.entries[0].UserID) } func TestLogout_RecordsAudit(t *testing.T) { svc, _ := newServiceWithUser(t, "a@b.c", "password123") // 先用 nil audit 走一遍 Login tok, _, err := svc.Login(context.Background(), "a@b.c", "password123", "127.0.0.1") require.NoError(t, err) // 替换 audit recorder 注入 spy 后 Logout spy := &spyAudit{} svc.audit = spy require.NoError(t, svc.Logout(context.Background(), tok)) require.Len(t, spy.entries, 1) require.Equal(t, "user.logout", spy.entries[0].Action) }