feat: 配置用户公司授权与默认公司

This commit is contained in:
文睿 张 2026-10-09 09:39:41 +08:00
parent ab6c2d6012
commit 5691e03f00
11 changed files with 687 additions and 25 deletions

View File

@ -7,7 +7,7 @@ import { organizationsApi } from '../api/organizations'
const RoleSelectStub = {
props: {
modelValue: Array,
modelValue: [Array, Number],
multiple: Boolean
},
emits: ['update:modelValue'],
@ -33,6 +33,7 @@ vi.mock('../stores/users', () => ({ useUsersStore: () => usersStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
vi.mock('../api/organizations', () => ({
organizationsApi: {
list: vi.fn().mockResolvedValue({ data: [{ id: 1, name: '甲公司', status: 'active' }, { id: 2, name: '乙公司', status: 'inactive' }] }),
listRoles: vi.fn()
}
}))
@ -147,6 +148,8 @@ describe('新增用户后列表与搜索框保持一致', () => {
state.form.username = 'lisi'
state.form.password = '123456'
state.form.role_ids = [2]
state.form.organization_ids = [1]
state.form.default_organization_id = 1
await state.handleSave()
await flushPromises()
@ -176,6 +179,86 @@ describe('新增用户后列表与搜索框保持一致', () => {
})
})
describe('用户公司授权', () => {
test('编辑普通用户非空授权时必须选择默认公司', async () => {
organizationsApi.listRoles.mockResolvedValue({ data: [{ id: 2, code: 'warehouse_admin', enabled: true, name: '仓库管理员' }] })
usersStore.updateUser.mockClear()
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
state.showEditModal({ id: 8, username: 'user', roles: [{ id: 2 }], organization_ids: [1], default_organization_id: null })
await state.handleSave()
expect(usersStore.updateUser).not.toHaveBeenCalled()
wrapper.unmount()
})
test('超级管理员无需逐项授权,可以选择启用公司作为默认公司', async () => {
organizationsApi.listRoles.mockResolvedValue({ data: [{ id: 1, code: 'super_admin', enabled: true, name: '超级管理员' }] })
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
state.showAddModal()
Object.assign(state.form, { username: 'admin2', password: 'secret', role_ids: [1], default_organization_id: 1 })
await state.handleSave()
expect(state.defaultOrganizations.map(item => item.id)).toEqual([1])
expect(usersStore.createUser).toHaveBeenLastCalledWith(expect.objectContaining({ organization_ids: [], default_organization_id: 1 }))
wrapper.unmount()
})
test('停用授权可以移除但不能重新分配给未持有的用户', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
state.showEditModal({ id: 8, username: 'user', roles: [{ id: 2 }], organization_ids: [2], default_organization_id: null })
await flushPromises()
const options = () => wrapper.findAllComponents(RoleOptionStub).filter(option => option.props('label') === '乙公司(已停用)')
expect(options()[0].props('disabled')).toBe(false)
state.form.organization_ids = []
await flushPromises()
expect(options()[0].props('disabled')).toBe(true)
wrapper.unmount()
})
test('编辑展示历史停用授权,默认公司只提供已选启用公司', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
state.showEditModal({ id: 8, username: 'user', roles: [{ id: 2 }], organization_ids: [1, 2], default_organization_id: 1 })
await flushPromises()
expect(state.form.organization_ids).toEqual([1, 2])
expect(wrapper.text()).toContain('乙公司(已停用)')
expect(state.defaultOrganizations.map(item => item.id)).toEqual([1])
state.form.organization_ids = [2]
await flushPromises()
expect(state.form.default_organization_id).toBeNull()
wrapper.unmount()
})
test('新增普通用户缺少公司授权时禁止保存', async () => {
usersStore.createUser.mockClear()
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
state.showAddModal()
Object.assign(state.form, { username: 'new', password: 'secret', role_ids: [2] })
await state.handleSave()
expect(usersStore.createUser).not.toHaveBeenCalled()
wrapper.unmount()
})
test('显式清空授权发送空集合和空默认公司', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
state.showEditModal({ id: 8, username: 'user', roles: [{ id: 2 }], organization_ids: [1], default_organization_id: 1 })
state.form.organization_ids = []
await flushPromises()
await state.handleSave()
expect(usersStore.updateUser).toHaveBeenLastCalledWith(8, expect.objectContaining({ organization_ids: [], default_organization_id: null }))
wrapper.unmount()
})
})
// 已停用角色在下拉里怎么呈现,直接决定"编辑用户"这条路走不走得通:保存是全量替换
// 角色,前端若摘不掉那个停用角色、后端又要求角色全部启用,这个用户就永远存不回去。
// 现在后端已放行"本来就持有的停用角色",前端这两条是配套:既让人摘得掉,也不让人

View File

@ -75,6 +75,22 @@
/>
</el-select>
</el-form-item>
<el-form-item label="可访问公司" :required="!editingId && !isSuperAdmin">
<el-select v-model="form.organization_ids" multiple style="width: 100%" placeholder="请选择可访问公司">
<el-option
v-for="organization in organizations"
:key="organization.id"
:label="organization.status === 'active' ? organization.name : `${organization.name}(已停用)`"
:value="organization.id"
:disabled="organization.status !== 'active' && !form.organization_ids.includes(organization.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="默认公司" :required="!editingId && !isSuperAdmin">
<el-select v-model="form.default_organization_id" clearable style="width: 100%" placeholder="请选择默认公司" @clear="form.default_organization_id = null">
<el-option v-for="organization in defaultOrganizations" :key="organization.id" :label="organization.name" :value="organization.id" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
@ -90,7 +106,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useUsersStore } from '../stores/users'
import { useAuthStore } from '../stores/auth'
import { organizationsApi } from '../api/organizations'
@ -110,20 +126,31 @@ const editingId = ref(null)
const deleteId = ref(null)
const searchKeyword = ref('')
const roles = ref([])
const organizations = ref([])
const form = reactive({
username: '',
password: '',
role_ids: [],
organization_ids: [],
default_organization_id: null,
description: ''
})
const isSuperAdmin = computed(() => roles.value.some(role => role.code === 'super_admin' && role.enabled && form.role_ids.includes(role.id)))
const defaultOrganizations = computed(() => organizations.value.filter(organization => organization.status === 'active' && (isSuperAdmin.value || form.organization_ids.includes(organization.id))))
watch(defaultOrganizations, available => {
if (form.default_organization_id != null && !available.some(organization => organization.id === form.default_organization_id)) {
form.default_organization_id = null
}
})
onMounted(() => {
if (!authStore.isAdmin) {
ElMessage.warning('无权限访问')
return
}
Promise.all([usersStore.fetchUsers(), loadRoles()])
Promise.all([usersStore.fetchUsers(), loadRoles(), loadOrganizations()])
})
async function loadRoles() {
@ -131,6 +158,11 @@ async function loadRoles() {
roles.value = response.data || []
}
async function loadOrganizations() {
const response = await organizationsApi.list()
organizations.value = response.data || []
}
// 列表的 tag 和编辑下拉都用它。已停用的角色照旧显示(用户可能正持有它,编辑时得
// 看得见、摘得掉),但必须把"已停用"标出来:停用意味着不再授予权限,只显示角色名
// 会让管理员以为这个角色还在生效。
@ -153,7 +185,7 @@ function showAddModal() {
}
editingId.value = null
dialogTitle.value = '新增用户'
Object.assign(form, { username: '', password: '', role_ids: [], description: '' })
Object.assign(form, { username: '', password: '', role_ids: [], organization_ids: [], default_organization_id: null, description: '' })
dialogVisible.value = true
}
@ -168,6 +200,8 @@ function showEditModal(row) {
username: row.username,
password: '',
role_ids: (row.roles || []).map(role => role.id),
organization_ids: [...(row.organization_ids || [])],
default_organization_id: row.default_organization_id ?? null,
description: row.description || ''
})
dialogVisible.value = true
@ -186,6 +220,14 @@ async function handleSave() {
ElMessage.warning('请至少选择一个角色')
return
}
if (!isSuperAdmin.value && ((!editingId.value && form.organization_ids.length === 0) || (form.organization_ids.length > 0 && form.default_organization_id == null))) {
ElMessage.warning('请选择可访问公司及默认公司')
return
}
if (form.default_organization_id != null && !defaultOrganizations.value.some(organization => organization.id === form.default_organization_id)) {
ElMessage.warning('默认公司必须为已授权的启用公司')
return
}
saveLoading.value = true
try {
const data = { ...form }

View File

@ -1063,6 +1063,7 @@ func (h *Handler) ListUsers(c *gin.Context) {
func (h *Handler) CreateUser(c *gin.Context) {
var req struct {
model.UserOrganizationInput
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
@ -1074,7 +1075,8 @@ func (h *Handler) CreateUser(c *gin.Context) {
return
}
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs)
req.ActorUserID = middleware.GetCurrentUserID(c)
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs, req.UserOrganizationInput)
if err != nil {
if message := roleErrorText(err); message != "" {
response.BadRequest(c, message)
@ -1095,6 +1097,7 @@ func (h *Handler) UpdateUser(c *gin.Context) {
}
var req struct {
model.UserOrganizationInput
Password string `json:"password"`
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
Description string `json:"description"`
@ -1105,7 +1108,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
return
}
err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password)
req.ActorUserID = middleware.GetCurrentUserID(c)
err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password, req.UserOrganizationInput)
if err != nil {
if message := roleErrorText(err); message != "" {
response.BadRequest(c, message)
@ -1150,6 +1154,8 @@ func (h *Handler) DeleteUser(c *gin.Context) {
// "请求参数有误",用户看不出问题出在角色上。
func roleErrorText(err error) string {
switch {
case errors.Is(err, repository.ErrInvalidOrganizations):
return err.Error()
case errors.Is(err, repository.ErrInvalidRoles):
return "所选角色无效或已被停用,请重新选择"
case errors.Is(err, repository.ErrLastSuperAdmin):

View File

@ -1,6 +1,7 @@
package model
import (
"encoding/json"
"fmt"
"time"
@ -305,13 +306,22 @@ type MaterialLog struct {
// User represents a system user
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Role string `json:"role"`
Roles []Role `json:"roles"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
OrganizationIDs []int64 `json:"organization_ids"`
DefaultOrganizationID *int64 `json:"default_organization_id"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Role string `json:"role"`
Roles []Role `json:"roles"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
// RawMessage distinguishes an omitted default from an explicit null on edits.
type UserOrganizationInput struct {
OrganizationIDs *[]int64 `json:"organization_ids"`
DefaultOrganizationID json.RawMessage `json:"default_organization_id"`
ActorUserID int64 `json:"-"`
}
// LoginRequest represents login request

View File

@ -48,7 +48,7 @@ func (r *UserRepository) GetByID(id int64) (*model.User, error) {
return user, nil
}
func (r *UserRepository) Create(user *model.User, roleIDs []int64) error {
func (r *UserRepository) Create(user *model.User, roleIDs []int64, organization ...model.UserOrganizationInput) error {
tx, err := database.DB.Begin()
if err != nil {
return err
@ -66,6 +66,11 @@ func (r *UserRepository) Create(user *model.User, roleIDs []int64) error {
if err = assignRolesTx(tx, user.ID, roleIDs); err != nil {
return err
}
if len(organization) > 0 {
if err := replaceUserOrganizationsTx(tx, user.ID, organization[0], true); err != nil {
return err
}
}
return tx.Commit()
}
@ -116,15 +121,15 @@ func (r *UserRepository) List(search string, limit, offset int) ([]model.User, i
return users, total, nil
}
func (r *UserRepository) Update(user *model.User, roleIDs []int64) error {
return r.update(user, roleIDs, false)
func (r *UserRepository) Update(user *model.User, roleIDs []int64, organization ...model.UserOrganizationInput) error {
return r.update(user, roleIDs, false, organization...)
}
func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64) error {
return r.update(user, roleIDs, true)
func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64, organization ...model.UserOrganizationInput) error {
return r.update(user, roleIDs, true, organization...)
}
func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePassword bool) error {
func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePassword bool, organization ...model.UserOrganizationInput) error {
tx, err := database.DB.Begin()
if err != nil {
return err
@ -138,6 +143,10 @@ func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePasswor
if err := protectLastSuperAdminTx(tx, user.ID, roleIDs); err != nil {
return err
}
var lockedID int64
if err := tx.QueryRow(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Scan(&lockedID); err != nil {
return err
}
if updatePassword {
_, err = tx.Exec(`UPDATE users SET password = ?, description = ? WHERE id = ?`, user.Password, user.Description, user.ID)
} else {
@ -152,6 +161,11 @@ func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePasswor
if err = insertUserRolesTx(tx, user.ID, roleIDs); err != nil {
return err
}
if len(organization) > 0 {
if err := replaceUserOrganizationsTx(tx, user.ID, organization[0], false); err != nil {
return err
}
}
return tx.Commit()
}
@ -203,7 +217,11 @@ func loadRolesForUsers(queryer interface {
user.Roles = append(user.Roles, role)
}
}
return rows.Err()
if err := rows.Err(); err != nil {
return err
}
rows.Close()
return loadOrganizationsForUsers(queryer, users)
}
func assignRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {

View File

@ -0,0 +1,192 @@
package repository
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/wmms/wmms/internal/model"
)
var ErrInvalidOrganizations = errors.New("公司授权或默认公司无效,请选择启用且已授权的公司")
type userOrganizationState struct {
UserID int64 `json:"user_id"`
OrganizationIDs []int64 `json:"organization_ids"`
DefaultOrganizationID *int64 `json:"default_organization_id"`
}
func replaceUserOrganizationsTx(tx *sql.Tx, userID int64, input model.UserOrganizationInput, creating bool) error {
if input.OrganizationIDs == nil && len(input.DefaultOrganizationID) == 0 && !creating {
return nil
}
ids := []int64{}
if input.OrganizationIDs != nil {
for _, id := range *input.OrganizationIDs {
if id <= 0 {
return ErrInvalidOrganizations
}
ids = append(ids, id)
}
ids = uniqueRoleIDs(ids)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
}
old := userOrganizationState{UserID: userID, OrganizationIDs: []int64{}}
rows, err := tx.Query(`SELECT organization_id, is_default FROM user_organizations WHERE user_id = ? ORDER BY organization_id FOR UPDATE`, userID)
if err != nil {
return err
}
for rows.Next() {
var id int64
var def bool
if err := rows.Scan(&id, &def); err != nil {
rows.Close()
return err
}
old.OrganizationIDs = append(old.OrganizationIDs, id)
if def {
value := id
old.DefaultOrganizationID = &value
}
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if input.OrganizationIDs == nil {
ids = append(ids, old.OrganizationIDs...)
}
// Decode into an independent value so replacing the default cannot mutate
// the original state retained for the audit record.
var def *int64
if old.DefaultOrganizationID != nil {
value := *old.DefaultOrganizationID
def = &value
}
if len(input.DefaultOrganizationID) > 0 {
if err := json.Unmarshal(input.DefaultOrganizationID, &def); err != nil {
return ErrInvalidOrganizations
}
}
if def != nil && *def <= 0 {
return ErrInvalidOrganizations
}
var admin bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON r.id=ur.role_id WHERE ur.user_id=? AND r.code='super_admin' AND r.enabled=1)`, userID).Scan(&admin); err != nil {
return err
}
contains := func(values []int64, id int64) bool {
for _, value := range values {
if value == id {
return true
}
}
return false
}
if !admin {
if creating && (len(ids) == 0 || def == nil) {
return ErrInvalidOrganizations
}
if len(ids) > 0 && def == nil {
return ErrInvalidOrganizations
}
if def != nil && !contains(ids, *def) {
return ErrInvalidOrganizations
}
} else if def != nil && !contains(ids, *def) {
ids = append(ids, *def)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
}
if len(ids) > 0 {
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
rows, err := tx.Query(fmt.Sprintf(`SELECT id, status FROM organizations WHERE id IN (%s) ORDER BY id FOR UPDATE`, placeholders), args...)
if err != nil {
return err
}
count := 0
invalid := false
for rows.Next() {
var id int64
var status string
if err := rows.Scan(&id, &status); err != nil {
rows.Close()
return err
}
count++
if status != "active" && (!contains(old.OrganizationIDs, id) || (def != nil && *def == id)) {
invalid = true
}
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if invalid || count != len(ids) {
return ErrInvalidOrganizations
}
}
if _, err := tx.Exec(`DELETE FROM user_organizations WHERE user_id = ?`, userID); err != nil {
return err
}
for _, id := range ids {
if _, err := tx.Exec(`INSERT INTO user_organizations (user_id, organization_id, is_default) VALUES (?, ?, ?)`, userID, id, def != nil && *def == id); err != nil {
return err
}
}
next := userOrganizationState{UserID: userID, OrganizationIDs: ids, DefaultOrganizationID: def}
oldJSON, err := json.Marshal(old)
if err != nil {
return err
}
newJSON, err := json.Marshal(next)
if err != nil {
return err
}
return writeOrganizationAudit(tx, &model.OrganizationAuditLog{ActorUserID: input.ActorUserID, Action: "organization_access_changed", OldValue: oldJSON, NewValue: newJSON})
}
func loadOrganizationsForUsers(q interface {
Query(string, ...interface{}) (*sql.Rows, error)
}, users []*model.User) error {
if len(users) == 0 {
return nil
}
args := make([]interface{}, len(users))
byID := map[int64]*model.User{}
for i, user := range users {
user.OrganizationIDs = []int64{}
user.DefaultOrganizationID = nil
args[i] = user.ID
byID[user.ID] = user
}
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(users)), ", ")
rows, err := q.Query(fmt.Sprintf(`SELECT user_id, organization_id, is_default FROM user_organizations WHERE user_id IN (%s) ORDER BY user_id, organization_id`, placeholders), args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID, id int64
var def bool
if err := rows.Scan(&userID, &id, &def); err != nil {
return err
}
if user := byID[userID]; user != nil {
user.OrganizationIDs = append(user.OrganizationIDs, id)
if def {
value := id
user.DefaultOrganizationID = &value
}
}
}
return rows.Err()
}

View File

@ -0,0 +1,133 @@
package repository
import (
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/wmms/wmms/internal/model"
)
// Exercise actual commits and rollbacks: a mock rollback alone cannot prove
// that role, grant and audit writes remain atomic or leave other users intact.
func TestUserOrganizationConfigurationMySQL(t *testing.T) {
db := isolationDB(t, false)
mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES(100,'actor','x');
INSERT INTO organizations(id,code,name,status) VALUES(2,'TWO','Two','active'),(3,'THREE','Three','inactive');`)
input := func(ids []int64, def string) model.UserOrganizationInput {
return model.UserOrganizationInput{OrganizationIDs: &ids, DefaultOrganizationID: json.RawMessage(def), ActorUserID: 100}
}
repo := NewUserRepository()
ordinary := &model.User{Username: "ordinary", Password: "x", Description: "before"}
if err := repo.Create(ordinary, []int64{2}, input([]int64{2, 1, 2}, "2")); err != nil {
t.Fatal(err)
}
other := &model.User{Username: "other", Password: "x"}
if err := repo.Create(other, []int64{2}, input([]int64{1}, "1")); err != nil {
t.Fatal(err)
}
admin := &model.User{Username: "admin", Password: "x"}
if err := repo.Create(admin, []int64{1}, input(nil, "null")); err != nil {
t.Fatal(err)
}
read := func(id int64) *model.User {
t.Helper()
user, err := repo.GetByID(id)
if err != nil {
t.Fatal(err)
}
return user
}
assertGrants := func(id int64, ids []int64, def *int64) {
t.Helper()
user := read(id)
if !reflect.DeepEqual(user.OrganizationIDs, ids) || !reflect.DeepEqual(user.DefaultOrganizationID, def) {
t.Fatalf("user %d grants=%v default=%v, want %v/%v", id, user.OrganizationIDs, user.DefaultOrganizationID, ids, def)
}
}
one, two := int64(1), int64(2)
assertGrants(ordinary.ID, []int64{1, 2}, &two)
assertGrants(admin.ID, []int64{}, nil)
if err := repo.Update(admin, []int64{1}, model.UserOrganizationInput{DefaultOrganizationID: json.RawMessage("2"), ActorUserID: 100}); err != nil {
t.Fatal(err)
}
assertGrants(admin.ID, []int64{2}, &two)
// Historical inactive grants survive detail/list reads and an explicit save,
// but new disabled grants and disabled defaults remain invalid.
mustSQL(t, db, `INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(?,3,0)`, ordinary.ID)
assertGrants(ordinary.ID, []int64{1, 2, 3}, &two)
if err := repo.Update(ordinary, []int64{2}, input([]int64{1, 2, 3}, "2")); err != nil {
t.Fatal(err)
}
if err := repo.Update(ordinary, []int64{2}, model.UserOrganizationInput{}); err != nil {
t.Fatal(err)
}
assertGrants(ordinary.ID, []int64{1, 2, 3}, &two)
if err := repo.Update(ordinary, []int64{2}, input([]int64{1, 2}, "2")); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
ids []int64
def string
}{
{"new inactive grant", []int64{1, 3}, "1"},
{"missing company", []int64{999}, "999"},
{"default outside grants", []int64{1}, "2"},
{"missing default", []int64{1}, "null"},
{"inactive default", []int64{3}, "3"},
} {
t.Run(tc.name, func(t *testing.T) {
if err := repo.Update(ordinary, []int64{3}, input(tc.ids, tc.def)); !errors.Is(err, ErrInvalidOrganizations) {
t.Fatalf("got %v", err)
}
if read(ordinary.ID).Roles[0].ID != 2 {
t.Fatal("invalid grants persisted replacement roles")
}
assertGrants(ordinary.ID, []int64{1, 2}, &two)
})
}
var auditCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM organization_audit_logs`).Scan(&auditCount); err != nil {
t.Fatal(err)
}
// Force a failure at the final write after user, role and grant replacement.
mustSQL(t, db, `CREATE TRIGGER reject_access_audit BEFORE INSERT ON organization_audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit rejected'`)
ordinary.Description = "must rollback"
if err := repo.Update(ordinary, []int64{3}, input([]int64{}, "null")); err == nil {
t.Fatal("audit failure accepted")
}
stored := read(ordinary.ID)
if stored.Description != "before" || stored.Roles[0].ID != 2 {
t.Fatalf("user/roles failed rollback: %+v", stored)
}
assertGrants(ordinary.ID, []int64{1, 2}, &two)
var afterCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM organization_audit_logs`).Scan(&afterCount); err != nil || afterCount != auditCount {
t.Fatalf("audit rollback: count=%d err=%v", afterCount, err)
}
mustSQL(t, db, `DROP TRIGGER reject_access_audit`)
if err := repo.Update(ordinary, []int64{999}, input([]int64{}, "null")); !errors.Is(err, ErrInvalidRoles) {
t.Fatalf("invalid role error=%v", err)
}
assertGrants(ordinary.ID, []int64{1, 2}, &two)
if err := repo.Update(ordinary, []int64{2}, input([]int64{}, "null")); err != nil {
t.Fatal(err)
}
assertGrants(ordinary.ID, []int64{}, nil)
assertGrants(other.ID, []int64{1}, &one)
if err := repo.Update(admin, []int64{2}, input([]int64{1}, "1")); !errors.Is(err, ErrLastSuperAdmin) {
t.Fatalf("last admin error=%v", err)
}
// A historical disabled super-admin role grants no exemption from creation
// requirements. Validation must read enabled DB roles, not the legacy Role.
mustSQL(t, db, `UPDATE roles SET enabled=0 WHERE code='super_admin'`)
invalid := &model.User{Username: "disabled-admin", Password: "x", Role: "super_admin"}
if err := repo.Create(invalid, []int64{2}, input(nil, "null")); !errors.Is(err, ErrInvalidOrganizations) {
t.Fatalf("legacy admin bypass: %v", err)
}
}

View File

@ -0,0 +1,140 @@
package repository
import (
"encoding/json"
"errors"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
func TestUserOrganizationReplacement(t *testing.T) {
for _, tc := range []struct {
name string
ids []int64
def string
active int
valid bool
}{
{"deduplicates", []int64{2, 2}, "2", 1, true},
{"disabled", []int64{2}, "2", 0, false},
{"missing", []int64{9}, "9", 0, false},
{"default outside set", []int64{2}, "3", 1, false},
{"nonempty without default", []int64{2}, "null", 1, false},
{"invalid id", []int64{-1}, "null", 0, false},
{"revoke all", []int64{}, "null", 0, true},
{"empty with default", []int64{}, "2", 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
mock.ExpectBegin()
tx, _ := db.Begin()
if tc.name != "invalid id" {
mock.ExpectQuery("SELECT organization_id, is_default FROM user_organizations").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"organization_id", "is_default"}).AddRow(1, true))
mock.ExpectQuery("SELECT EXISTS").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"admin"}).AddRow(false))
if len(tc.ids) > 0 && tc.name != "default outside set" && tc.name != "nonempty without default" {
mock.ExpectQuery("SELECT id, status FROM organizations").WithArgs(tc.ids[0]).WillReturnRows(func() *sqlmock.Rows {
rows := sqlmock.NewRows([]string{"id", "status"})
if tc.active > 0 {
rows.AddRow(tc.ids[0], "active")
}
return rows
}())
}
}
if tc.valid {
mock.ExpectExec("DELETE FROM user_organizations").WithArgs(int64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
if len(tc.ids) > 0 {
mock.ExpectExec("INSERT INTO user_organizations").WithArgs(int64(7), int64(2), true).WillReturnResult(sqlmock.NewResult(0, 1))
}
mock.ExpectExec("INSERT INTO organization_audit_logs").WithArgs(int64(99), nil, "organization_access_changed", sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
}
err := replaceUserOrganizationsTx(tx, 7, model.UserOrganizationInput{OrganizationIDs: &tc.ids, DefaultOrganizationID: json.RawMessage(tc.def), ActorUserID: 99}, false)
if tc.valid && err != nil {
t.Fatal(err)
}
if !tc.valid && !errors.Is(err, ErrInvalidOrganizations) {
t.Fatalf("want invalid organizations, got %v", err)
}
mock.ExpectRollback()
_ = tx.Rollback()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
func TestUserOrganizationOmittedPreservesAccess(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
mock.ExpectBegin()
tx, _ := db.Begin()
if err := replaceUserOrganizationsTx(tx, 7, model.UserOrganizationInput{ActorUserID: 99}, false); err != nil {
t.Fatal(err)
}
mock.ExpectRollback()
_ = tx.Rollback()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUserOrganizationDefaultChangeAuditsOriginalDefault(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
mock.ExpectQuery("SELECT organization_id, is_default FROM user_organizations").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"organization_id", "is_default"}).AddRow(1, false).AddRow(2, true))
mock.ExpectQuery("SELECT EXISTS").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"admin"}).AddRow(false))
mock.ExpectQuery("SELECT id, status FROM organizations").WithArgs(int64(1), int64(2)).WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(1, "active").AddRow(2, "active"))
mock.ExpectExec("DELETE FROM user_organizations").WithArgs(int64(7)).WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec("INSERT INTO user_organizations").WithArgs(int64(7), int64(1), true).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO user_organizations").WithArgs(int64(7), int64(2), false).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO organization_audit_logs").WithArgs(int64(99), nil, "organization_access_changed", `{"user_id":7,"organization_ids":[1,2],"default_organization_id":2}`, `{"user_id":7,"organization_ids":[1,2],"default_organization_id":1}`).WillReturnResult(sqlmock.NewResult(1, 1))
if err := replaceUserOrganizationsTx(tx, 7, model.UserOrganizationInput{DefaultOrganizationID: json.RawMessage("1"), ActorUserID: 99}, false); err != nil {
t.Fatal(err)
}
mock.ExpectRollback()
_ = tx.Rollback()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUserOrganizationAuditFailureRollsBackUserAndRoles(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
database.DB = db
defer func() { database.DB = nil }()
mock.ExpectBegin()
mock.ExpectQuery("SELECT COUNT.*FROM roles").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery("SELECT id FROM roles WHERE code").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectQuery("SELECT COUNT.*FROM user_roles WHERE user_id").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery("SELECT id FROM users WHERE id.*FOR UPDATE").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
mock.ExpectExec("UPDATE users").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE FROM user_roles").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO user_roles").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT organization_id, is_default FROM user_organizations").WillReturnRows(sqlmock.NewRows([]string{"organization_id", "is_default"}).AddRow(2, true))
mock.ExpectQuery("SELECT EXISTS").WillReturnRows(sqlmock.NewRows([]string{"admin"}).AddRow(false))
mock.ExpectExec("DELETE FROM user_organizations").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO organization_audit_logs").WillReturnError(errors.New("audit unavailable"))
mock.ExpectRollback()
ids := []int64{}
err := NewUserRepository().Update(&model.User{ID: 7}, []int64{2}, model.UserOrganizationInput{OrganizationIDs: &ids, DefaultOrganizationID: json.RawMessage("null"), ActorUserID: 99})
if err == nil || err.Error() != "audit unavailable" {
t.Fatalf("got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -35,6 +35,7 @@ func TestListUsersIncludesAllAssignedRoles(t *testing.T) {
AddRow(7, 2, "warehouse_admin", "仓库管理员", true, now, now).
AddRow(7, 3, "purchaser", "采购人员", true, now, now))
mock.ExpectQuery("SELECT user_id, organization_id, is_default FROM user_organizations").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"user_id", "organization_id", "is_default"}).AddRow(7, 2, true).AddRow(7, 9, false))
users, total, err := NewUserRepository().List("", 20, 0)
if err != nil {
t.Fatalf("list users: %v", err)
@ -45,6 +46,9 @@ func TestListUsersIncludesAllAssignedRoles(t *testing.T) {
if users[0].Roles[1].Name != "采购人员" {
t.Fatalf("role names not loaded: %+v", users[0].Roles)
}
if len(users[0].OrganizationIDs) != 2 || users[0].OrganizationIDs[1] != 9 || users[0].DefaultOrganizationID == nil || *users[0].DefaultOrganizationID != 2 {
t.Fatalf("organization grants not loaded: %+v", users[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
@ -137,6 +141,7 @@ func TestUpdateKeepsDisabledRoleAlreadyAssignedToUser(t *testing.T) {
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`)).
WithArgs(int64(7), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery("SELECT id FROM users WHERE id.*FOR UPDATE").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE users SET description = ? WHERE id = ?`)).
WithArgs("改个描述", int64(7)).
WillReturnResult(sqlmock.NewResult(0, 1))

View File

@ -158,7 +158,7 @@ func (s *AuthService) ListUsers(search string, limit, offset int) ([]model.User,
}
// CreateUser creates a new user
func (s *AuthService) CreateUser(username, password, description string, roleIDs []int64) (*model.User, error) {
func (s *AuthService) CreateUser(username, password, description string, roleIDs []int64, organization ...model.UserOrganizationInput) (*model.User, error) {
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@ -171,14 +171,20 @@ func (s *AuthService) CreateUser(username, password, description string, roleIDs
Description: description,
}
if err := s.userRepo.Create(user, roleIDs); err != nil {
// Service callers always validate company access. Repository calls without
// input are reserved for the legacy bootstrap users created before migration.
input := model.UserOrganizationInput{}
if len(organization) > 0 {
input = organization[0]
}
if err := s.userRepo.Create(user, roleIDs, input); err != nil {
return nil, err
}
return s.userRepo.GetByID(user.ID)
}
// UpdateUser updates a user (admin function)
func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64, newPassword string) error {
func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64, newPassword string, organization ...model.UserOrganizationInput) error {
user, err := s.userRepo.GetByID(id)
if err != nil {
return err
@ -192,10 +198,10 @@ func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64,
return err
}
user.Password = string(hashedPassword)
return s.userRepo.UpdateWithPassword(user, roleIDs)
return s.userRepo.UpdateWithPassword(user, roleIDs, organization...)
}
return s.userRepo.Update(user, roleIDs)
return s.userRepo.Update(user, roleIDs, organization...)
}
// DeleteUser deletes a user

View File

@ -1,9 +1,13 @@
package service
import (
"errors"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/pkg/database"
)
func TestGeneratedTokenCarriesEveryUserRole(t *testing.T) {
@ -29,3 +33,26 @@ func TestGeneratedTokenCarriesEveryUserRole(t *testing.T) {
t.Fatalf("unexpected token roles: %+v", token.Roles)
}
}
func TestUserCreateOrganizationOmittedRejectsOrdinaryUser(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO users").WithArgs("new", sqlmock.AnyArg(), "").WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectQuery("SELECT COUNT.*FROM roles").WithArgs(int64(2), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectExec("INSERT INTO user_roles").WithArgs(int64(7), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT organization_id, is_default FROM user_organizations").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"organization_id", "is_default"}))
mock.ExpectQuery("SELECT EXISTS").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"admin"}).AddRow(false))
mock.ExpectRollback()
_, err = NewAuthService().CreateUser("new", "secret", "", []int64{2})
if !errors.Is(err, repository.ErrInvalidOrganizations) {
t.Fatalf("want invalid organizations, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}