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) { out := make([]*User, 0) for _, u := range r.users { if u.IsAdmin { out = append(out, u) } } return out, nil } func (r *fakeRepo) CountAdmins(_ context.Context) (int, error) { n := 0 for _, u := range r.users { if u.IsAdmin && u.Enabled { n++ } } return n, nil } func (r *fakeRepo) ListUsers(_ context.Context) ([]*User, error) { out := make([]*User, 0, len(r.users)) for _, u := range r.users { out = append(out, u) } return out, nil } func (r *fakeRepo) findByID(id uuid.UUID) (*User, bool) { for _, u := range r.users { if u.ID == id { return u, true } } return nil, false } func (r *fakeRepo) UpdateProfile(_ context.Context, id uuid.UUID, displayName string) (*User, error) { u, ok := r.findByID(id) if !ok { return nil, errs.New(errs.CodeNotFound, "no user") } u.DisplayName = displayName return u, nil } func (r *fakeRepo) SetAdmin(_ context.Context, id uuid.UUID, isAdmin bool) (*User, error) { u, ok := r.findByID(id) if !ok { return nil, errs.New(errs.CodeNotFound, "no user") } u.IsAdmin = isAdmin return u, nil } func (r *fakeRepo) SetEnabled(_ context.Context, id uuid.UUID, enabled bool) (*User, error) { u, ok := r.findByID(id) if !ok { return nil, errs.New(errs.CodeNotFound, "no user") } u.Enabled = enabled return u, nil } func (r *fakeRepo) UpdatePassword(_ context.Context, id uuid.UUID, passwordHash string) error { u, ok := r.findByID(id) if !ok { return errs.New(errs.CodeNotFound, "no user") } u.PasswordHash = passwordHash return nil } func (r *fakeRepo) DeleteUser(_ context.Context, id uuid.UUID) error { for email, u := range r.users { if u.ID == id { delete(r.users, email) return nil } } return nil } func (r *fakeRepo) DeleteSessionsByUserID(_ context.Context, userID uuid.UUID) error { for k, s := range r.sessions { if s.UserID == userID { delete(r.sessions, k) } } return 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) } // ===== 管理员用户管理测试 ===== // newAdminRepo 构造一个含一个启用管理员的 fakeRepo + service。 func newAdminRepo(t *testing.T) (*service, *fakeRepo, *User) { t.Helper() repo := newFakeRepo() hash, err := HashPassword("password123") require.NoError(t, err) admin := &User{ID: uuid.New(), Email: "admin@b.c", DisplayName: "Admin", PasswordHash: hash, IsAdmin: true, Enabled: true} repo.users[admin.Email] = admin svc := NewService(repo, nil).(*service) return svc, repo, admin } func TestCreateUser_AndListUsers(t *testing.T) { svc, _, _ := newAdminRepo(t) u, err := svc.CreateUser(context.Background(), CreateUserInput{ Email: "new@b.c", DisplayName: "New", Password: "password123", IsAdmin: false, }) require.NoError(t, err) require.Equal(t, "new@b.c", u.Email) users, err := svc.ListUsers(context.Background()) require.NoError(t, err) require.Len(t, users, 2) } func TestCreateUser_ShortPassword(t *testing.T) { svc, _, _ := newAdminRepo(t) _, err := svc.CreateUser(context.Background(), CreateUserInput{ Email: "new@b.c", DisplayName: "New", Password: "short", }) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeInvalidInput, ae.Code) } func TestSetAdmin_CannotDemoteSelf(t *testing.T) { svc, _, admin := newAdminRepo(t) _, err := svc.SetAdmin(context.Background(), admin.ID, admin.ID, false) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeConflict, ae.Code) } func TestSetAdmin_CannotDemoteLastAdmin(t *testing.T) { svc, repo, admin := newAdminRepo(t) // 加一个普通用户作 actor,避免触发自锁分支 actor := &User{ID: uuid.New(), Email: "actor@b.c", DisplayName: "A", IsAdmin: true, Enabled: true} repo.users[actor.Email] = actor // 现在有 2 个 admin;降级其中一个应成功 _, err := svc.SetAdmin(context.Background(), actor.ID, admin.ID, false) require.NoError(t, err) // 只剩 actor 一个 admin;再降级 actor(由自己以外触发不可能,构造另一普通用户) plain := &User{ID: uuid.New(), Email: "p@b.c", DisplayName: "P", IsAdmin: false, Enabled: true} repo.users[plain.Email] = plain _, err = svc.SetAdmin(context.Background(), plain.ID, actor.ID, false) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeConflict, ae.Code) } func TestSetEnabled_DisableRevokesSessions(t *testing.T) { svc, repo, _ := newAdminRepo(t) target := &User{ID: uuid.New(), Email: "t@b.c", DisplayName: "T", IsAdmin: false, Enabled: true} repo.users[target.Email] = target repo.sessions["s1"] = &Session{UserID: target.ID, TokenHash: []byte("s1")} actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: true, Enabled: true} repo.users[actor.Email] = actor _, err := svc.SetEnabled(context.Background(), actor.ID, target.ID, false) require.NoError(t, err) require.False(t, repo.users["t@b.c"].Enabled) require.Empty(t, repo.sessions) } func TestChangePassword_WrongOld(t *testing.T) { svc, _, admin := newAdminRepo(t) err := svc.ChangePassword(context.Background(), admin.ID, "wrong-old", "newpassword123") ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeUnauthorized, ae.Code) } func TestChangePassword_Success(t *testing.T) { svc, repo, admin := newAdminRepo(t) repo.sessions["s1"] = &Session{UserID: admin.ID, TokenHash: []byte("s1")} err := svc.ChangePassword(context.Background(), admin.ID, "password123", "newpassword123") require.NoError(t, err) ok, err := VerifyPassword("newpassword123", repo.users["admin@b.c"].PasswordHash) require.NoError(t, err) require.True(t, ok) require.Empty(t, repo.sessions) // 改密撤销全部会话 } func TestAdminResetPassword_ReturnsTemp(t *testing.T) { svc, repo, admin := newAdminRepo(t) temp, err := svc.AdminResetPassword(context.Background(), admin.ID) require.NoError(t, err) require.GreaterOrEqual(t, len(temp), minPasswordLen) ok, err := VerifyPassword(temp, repo.users["admin@b.c"].PasswordHash) require.NoError(t, err) require.True(t, ok) } func TestDeleteUser_CannotDeleteSelf(t *testing.T) { svc, _, admin := newAdminRepo(t) err := svc.DeleteUser(context.Background(), admin.ID, admin.ID) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeConflict, ae.Code) } func TestDeleteUser_CannotDeleteLastAdmin(t *testing.T) { svc, repo, admin := newAdminRepo(t) actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: false, Enabled: true} repo.users[actor.Email] = actor err := svc.DeleteUser(context.Background(), actor.ID, admin.ID) ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeConflict, ae.Code) }