feat: 配置用户公司授权与默认公司
This commit is contained in:
parent
ab6c2d6012
commit
5691e03f00
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
// 已停用角色在下拉里怎么呈现,直接决定"编辑用户"这条路走不走得通:保存是全量替换
|
||||
// 角色,前端若摘不掉那个停用角色、后端又要求角色全部启用,这个用户就永远存不回去。
|
||||
// 现在后端已放行"本来就持有的停用角色",前端这两条是配套:既让人摘得掉,也不让人
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
192
internal/repository/user_organization.go
Normal file
192
internal/repository/user_organization.go
Normal 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()
|
||||
}
|
||||
133
internal/repository/user_organization_integration_test.go
Normal file
133
internal/repository/user_organization_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
140
internal/repository/user_organization_test.go
Normal file
140
internal/repository/user_organization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user