feat: 配置用户公司授权与默认公司
This commit is contained in:
parent
7e56d462b3
commit
8043fa198b
|
|
@ -7,7 +7,7 @@ import { organizationsApi } from '../api/organizations'
|
||||||
|
|
||||||
const RoleSelectStub = {
|
const RoleSelectStub = {
|
||||||
props: {
|
props: {
|
||||||
modelValue: Array,
|
modelValue: [Array, Number],
|
||||||
multiple: Boolean
|
multiple: Boolean
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
|
|
@ -33,6 +33,7 @@ vi.mock('../stores/users', () => ({ useUsersStore: () => usersStore }))
|
||||||
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
|
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
|
||||||
vi.mock('../api/organizations', () => ({
|
vi.mock('../api/organizations', () => ({
|
||||||
organizationsApi: {
|
organizationsApi: {
|
||||||
|
list: vi.fn().mockResolvedValue({ data: [{ id: 1, name: '甲公司', status: 'active' }, { id: 2, name: '乙公司', status: 'inactive' }] }),
|
||||||
listRoles: vi.fn()
|
listRoles: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
@ -147,6 +148,8 @@ describe('新增用户后列表与搜索框保持一致', () => {
|
||||||
state.form.username = 'lisi'
|
state.form.username = 'lisi'
|
||||||
state.form.password = '123456'
|
state.form.password = '123456'
|
||||||
state.form.role_ids = [2]
|
state.form.role_ids = [2]
|
||||||
|
state.form.organization_ids = [1]
|
||||||
|
state.form.default_organization_id = 1
|
||||||
await state.handleSave()
|
await state.handleSave()
|
||||||
await flushPromises()
|
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-select>
|
||||||
</el-form-item>
|
</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-form-item label="描述">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
@ -90,7 +106,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||||
import { useUsersStore } from '../stores/users'
|
import { useUsersStore } from '../stores/users'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { organizationsApi } from '../api/organizations'
|
import { organizationsApi } from '../api/organizations'
|
||||||
|
|
@ -110,20 +126,31 @@ const editingId = ref(null)
|
||||||
const deleteId = ref(null)
|
const deleteId = ref(null)
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const roles = ref([])
|
const roles = ref([])
|
||||||
|
const organizations = ref([])
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
role_ids: [],
|
role_ids: [],
|
||||||
|
organization_ids: [],
|
||||||
|
default_organization_id: null,
|
||||||
description: ''
|
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(() => {
|
onMounted(() => {
|
||||||
if (!authStore.isAdmin) {
|
if (!authStore.isAdmin) {
|
||||||
ElMessage.warning('无权限访问')
|
ElMessage.warning('无权限访问')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Promise.all([usersStore.fetchUsers(), loadRoles()])
|
Promise.all([usersStore.fetchUsers(), loadRoles(), loadOrganizations()])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadRoles() {
|
async function loadRoles() {
|
||||||
|
|
@ -131,6 +158,11 @@ async function loadRoles() {
|
||||||
roles.value = response.data || []
|
roles.value = response.data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOrganizations() {
|
||||||
|
const response = await organizationsApi.list()
|
||||||
|
organizations.value = response.data || []
|
||||||
|
}
|
||||||
|
|
||||||
// 列表的 tag 和编辑下拉都用它。已停用的角色照旧显示(用户可能正持有它,编辑时得
|
// 列表的 tag 和编辑下拉都用它。已停用的角色照旧显示(用户可能正持有它,编辑时得
|
||||||
// 看得见、摘得掉),但必须把"已停用"标出来:停用意味着不再授予权限,只显示角色名
|
// 看得见、摘得掉),但必须把"已停用"标出来:停用意味着不再授予权限,只显示角色名
|
||||||
// 会让管理员以为这个角色还在生效。
|
// 会让管理员以为这个角色还在生效。
|
||||||
|
|
@ -153,7 +185,7 @@ function showAddModal() {
|
||||||
}
|
}
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
dialogTitle.value = '新增用户'
|
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
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,6 +200,8 @@ function showEditModal(row) {
|
||||||
username: row.username,
|
username: row.username,
|
||||||
password: '',
|
password: '',
|
||||||
role_ids: (row.roles || []).map(role => role.id),
|
role_ids: (row.roles || []).map(role => role.id),
|
||||||
|
organization_ids: [...(row.organization_ids || [])],
|
||||||
|
default_organization_id: row.default_organization_id ?? null,
|
||||||
description: row.description || ''
|
description: row.description || ''
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
@ -186,6 +220,14 @@ async function handleSave() {
|
||||||
ElMessage.warning('请至少选择一个角色')
|
ElMessage.warning('请至少选择一个角色')
|
||||||
return
|
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
|
saveLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = { ...form }
|
const data = { ...form }
|
||||||
|
|
|
||||||
|
|
@ -1063,6 +1063,7 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
||||||
|
|
||||||
func (h *Handler) CreateUser(c *gin.Context) {
|
func (h *Handler) CreateUser(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
model.UserOrganizationInput
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
|
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
|
||||||
|
|
@ -1074,7 +1075,8 @@ func (h *Handler) CreateUser(c *gin.Context) {
|
||||||
return
|
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 err != nil {
|
||||||
if message := roleErrorText(err); message != "" {
|
if message := roleErrorText(err); message != "" {
|
||||||
response.BadRequest(c, message)
|
response.BadRequest(c, message)
|
||||||
|
|
@ -1095,6 +1097,7 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
|
model.UserOrganizationInput
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
|
RoleIDs []int64 `json:"role_ids" binding:"required,min=1"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
|
@ -1105,7 +1108,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||||
return
|
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 err != nil {
|
||||||
if message := roleErrorText(err); message != "" {
|
if message := roleErrorText(err); message != "" {
|
||||||
response.BadRequest(c, message)
|
response.BadRequest(c, message)
|
||||||
|
|
@ -1150,6 +1154,8 @@ func (h *Handler) DeleteUser(c *gin.Context) {
|
||||||
// "请求参数有误",用户看不出问题出在角色上。
|
// "请求参数有误",用户看不出问题出在角色上。
|
||||||
func roleErrorText(err error) string {
|
func roleErrorText(err error) string {
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrInvalidOrganizations):
|
||||||
|
return err.Error()
|
||||||
case errors.Is(err, repository.ErrInvalidRoles):
|
case errors.Is(err, repository.ErrInvalidRoles):
|
||||||
return "所选角色无效或已被停用,请重新选择"
|
return "所选角色无效或已被停用,请重新选择"
|
||||||
case errors.Is(err, repository.ErrLastSuperAdmin):
|
case errors.Is(err, repository.ErrLastSuperAdmin):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -305,6 +306,8 @@ type MaterialLog struct {
|
||||||
|
|
||||||
// User represents a system user
|
// User represents a system user
|
||||||
type User struct {
|
type User struct {
|
||||||
|
OrganizationIDs []int64 `json:"organization_ids"`
|
||||||
|
DefaultOrganizationID *int64 `json:"default_organization_id"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
|
|
@ -314,6 +317,13 @@ type User struct {
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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
|
// LoginRequest represents login request
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func (r *UserRepository) GetByID(id int64) (*model.User, error) {
|
||||||
return user, nil
|
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()
|
tx, err := database.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err = assignRolesTx(tx, user.ID, roleIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(organization) > 0 {
|
||||||
|
if err := replaceUserOrganizationsTx(tx, user.ID, organization[0], true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,15 +121,15 @@ func (r *UserRepository) List(search string, limit, offset int) ([]model.User, i
|
||||||
return users, total, nil
|
return users, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) Update(user *model.User, roleIDs []int64) error {
|
func (r *UserRepository) Update(user *model.User, roleIDs []int64, organization ...model.UserOrganizationInput) error {
|
||||||
return r.update(user, roleIDs, false)
|
return r.update(user, roleIDs, false, organization...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64) error {
|
func (r *UserRepository) UpdateWithPassword(user *model.User, roleIDs []int64, organization ...model.UserOrganizationInput) error {
|
||||||
return r.update(user, roleIDs, true)
|
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()
|
tx, err := database.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := protectLastSuperAdminTx(tx, user.ID, roleIDs); err != nil {
|
||||||
return err
|
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 {
|
if updatePassword {
|
||||||
_, err = tx.Exec(`UPDATE users SET password = ?, description = ? WHERE id = ?`, user.Password, user.Description, user.ID)
|
_, err = tx.Exec(`UPDATE users SET password = ?, description = ? WHERE id = ?`, user.Password, user.Description, user.ID)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -152,6 +161,11 @@ func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePasswor
|
||||||
if err = insertUserRolesTx(tx, user.ID, roleIDs); err != nil {
|
if err = insertUserRolesTx(tx, user.ID, roleIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(organization) > 0 {
|
||||||
|
if err := replaceUserOrganizationsTx(tx, user.ID, organization[0], false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +217,11 @@ func loadRolesForUsers(queryer interface {
|
||||||
user.Roles = append(user.Roles, role)
|
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 {
|
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, 2, "warehouse_admin", "仓库管理员", true, now, now).
|
||||||
AddRow(7, 3, "purchaser", "采购人员", 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)
|
users, total, err := NewUserRepository().List("", 20, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list users: %v", err)
|
t.Fatalf("list users: %v", err)
|
||||||
|
|
@ -45,6 +46,9 @@ func TestListUsersIncludesAllAssignedRoles(t *testing.T) {
|
||||||
if users[0].Roles[1].Name != "采购人员" {
|
if users[0].Roles[1].Name != "采购人员" {
|
||||||
t.Fatalf("role names not loaded: %+v", users[0].Roles)
|
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 {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatal(err)
|
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 = ?`)).
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`)).
|
||||||
WithArgs(int64(7), int64(1)).
|
WithArgs(int64(7), int64(1)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
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 = ?`)).
|
mock.ExpectExec(regexp.QuoteMeta(`UPDATE users SET description = ? WHERE id = ?`)).
|
||||||
WithArgs("改个描述", int64(7)).
|
WithArgs("改个描述", int64(7)).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
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
|
// 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
|
// Hash password
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -171,14 +171,20 @@ func (s *AuthService) CreateUser(username, password, description string, roleIDs
|
||||||
Description: description,
|
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 nil, err
|
||||||
}
|
}
|
||||||
return s.userRepo.GetByID(user.ID)
|
return s.userRepo.GetByID(user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser updates a user (admin function)
|
// 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)
|
user, err := s.userRepo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -192,10 +198,10 @@ func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64,
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
user.Password = string(hashedPassword)
|
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
|
// DeleteUser deletes a user
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
"github.com/wmms/wmms/internal/model"
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
"github.com/wmms/wmms/internal/repository"
|
||||||
|
"github.com/wmms/wmms/pkg/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGeneratedTokenCarriesEveryUserRole(t *testing.T) {
|
func TestGeneratedTokenCarriesEveryUserRole(t *testing.T) {
|
||||||
|
|
@ -29,3 +33,26 @@ func TestGeneratedTokenCarriesEveryUserRole(t *testing.T) {
|
||||||
t.Fatalf("unexpected token roles: %+v", token.Roles)
|
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