组织初始化接口 GET /api/auth/organizations 现在为每项授权公司附带
warehouse 摘要({id, name, status, location: {id, name, status} | null} | null),
结构与切换接口一致,供普通用户在公司切换器直接展示,无需放开管理员仓库配置接口。
- model:新增 WarehouseSummary/LocationSummary/OrganizationSummary,
OrganizationAccess 每项改为内嵌公司资料再加 warehouse 摘要。
- repository:新增 WarehouseRepository.Summary 复用切换接口的解析规则;
ListAccessible 逐公司补齐摘要,仓库或库位配置不唯一时报错而非随意取第一条,
无仓库、无库位或已停用如实返回,不回退旧公司数据。
- service:Switch 复用 List 已解析的摘要,响应格式保持不变。
- 测试:新增服务层 sqlmock 用例与仓储层 MySQL 集成用例,
覆盖启用唯一仓库加启用库位、无仓库、仓库停用三类。
232 lines
9.4 KiB
Go
232 lines
9.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/wmms/wmms/internal/model"
|
|
)
|
|
|
|
// Removing the role enabled predicate, trusting a previous login role, or
|
|
// omitting the organization status filter must expose unauthorized companies.
|
|
func TestOrganizationAccessUsesCurrentEnabledRolesAndGrants(t *testing.T) {
|
|
db := isolationDB(t, false)
|
|
mustSQL(t, db, `INSERT INTO organizations(id,code,name,status) VALUES(2,'TWO','Two','active'),(3,'THREE','Three','inactive');
|
|
INSERT INTO users(id,username,password) VALUES(1,'ordinary','x'),(2,'admin','x'),(3,'disabled-admin','x'),(4,'no-grants','x');
|
|
INSERT INTO user_roles(user_id,role_id) SELECT 2,id FROM roles WHERE code='super_admin';
|
|
INSERT INTO user_roles(user_id,role_id) SELECT 3,id FROM roles WHERE code='super_admin';
|
|
INSERT INTO roles(id,code,name,enabled) VALUES(20,'old_admin','Old',0);
|
|
INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(1,2,1),(1,3,0),(3,2,1);`)
|
|
repo := NewOrganizationRepository()
|
|
access, err := repo.ListAccessible(1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(access.Organizations) != 1 || access.Organizations[0].ID != 2 || access.DefaultOrganizationID == nil || *access.DefaultOrganizationID != 2 {
|
|
t.Fatalf("ordinary access=%+v", access)
|
|
}
|
|
allowed, err := repo.CanAccess(1, 1)
|
|
if err != nil || allowed {
|
|
t.Fatalf("ungranted organization allowed=%v err=%v", allowed, err)
|
|
}
|
|
allowed, err = repo.CanAccess(1, 3)
|
|
if err != nil || allowed {
|
|
t.Fatalf("inactive organization allowed=%v err=%v", allowed, err)
|
|
}
|
|
access, err = repo.ListAccessible(2)
|
|
if err != nil || len(access.Organizations) != 2 || access.DefaultOrganizationID != nil {
|
|
t.Fatalf("ungranted admin access=%+v err=%v", access, err)
|
|
}
|
|
allowed, err = repo.CanAccess(2, 2)
|
|
if err != nil || !allowed {
|
|
t.Fatalf("enabled admin access=%v err=%v", allowed, err)
|
|
}
|
|
mustSQL(t, db, `DELETE FROM user_organizations WHERE user_id=1`)
|
|
access, err = repo.ListAccessible(1)
|
|
if err != nil || len(access.Organizations) != 0 || access.DefaultOrganizationID != nil {
|
|
t.Fatalf("revoked grant access=%+v err=%v", access, err)
|
|
}
|
|
allowed, err = repo.CanAccess(1, 2)
|
|
if err != nil || allowed {
|
|
t.Fatalf("revoked grant allowed=%v err=%v", allowed, err)
|
|
}
|
|
mustSQL(t, db, `UPDATE roles SET enabled=0 WHERE code='super_admin'`)
|
|
access, err = repo.ListAccessible(2)
|
|
if err != nil || len(access.Organizations) != 0 {
|
|
t.Fatalf("disabled admin access=%+v err=%v", access, err)
|
|
}
|
|
allowed, err = repo.CanAccess(2, 2)
|
|
if err != nil || allowed {
|
|
t.Fatalf("disabled admin allowed=%v err=%v", allowed, err)
|
|
}
|
|
access, err = repo.ListAccessible(3)
|
|
if err != nil || len(access.Organizations) != 1 || access.Organizations[0].ID != 2 {
|
|
t.Fatalf("ordinary grant with disabled role access=%+v err=%v", access, err)
|
|
}
|
|
allowed, err = repo.CanAccess(999, 2)
|
|
if err != nil || allowed {
|
|
t.Fatalf("nonexistent user allowed=%v err=%v", allowed, err)
|
|
}
|
|
}
|
|
|
|
// 组织初始化列表必须为每家公司附带与切换接口一致的仓库、库位摘要。
|
|
// 覆盖:启用唯一仓库加启用库位、无仓库、仓库停用三类,均如实返回,不回退旧数据。
|
|
func TestListAccessibleIncludesWarehouseSummary(t *testing.T) {
|
|
db := isolationDB(t, false)
|
|
mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES(9,'summary-admin','x');
|
|
INSERT INTO user_roles(user_id,role_id) SELECT 9,id FROM roles WHERE code='super_admin';
|
|
INSERT INTO organizations(id,code,name,status) VALUES
|
|
(2,'TWO','无仓库公司','active'),
|
|
(3,'THREE','停用仓库公司','active'),
|
|
(4,'FOUR','启用仓库公司','active');
|
|
INSERT INTO warehouses(id,organization_id,code,name,status) VALUES
|
|
(30,3,'D3','停用仓库','inactive'),
|
|
(40,4,'D4','启用仓库','active');
|
|
INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES
|
|
(31,30,'L3','停用仓库的库位','active'),
|
|
(41,40,'L4','启用仓库的库位','active');`)
|
|
access, err := NewOrganizationRepository().ListAccessible(9)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
byID := make(map[int64]model.OrganizationSummary, len(access.Organizations))
|
|
for _, item := range access.Organizations {
|
|
byID[item.ID] = item
|
|
}
|
|
if len(byID) != 4 {
|
|
t.Fatalf("应返回四家授权公司,实际 %+v", access.Organizations)
|
|
}
|
|
// 启用唯一仓库加启用库位:摘要含仓库与库位,状态如实透传。
|
|
enabled := byID[4]
|
|
if enabled.Warehouse == nil || enabled.Warehouse.ID != 40 || enabled.Warehouse.Status != "active" ||
|
|
enabled.Warehouse.Location == nil || enabled.Warehouse.Location.ID != 41 || enabled.Warehouse.Location.Status != "active" {
|
|
t.Fatalf("启用仓库摘要错误 %+v", enabled.Warehouse)
|
|
}
|
|
// 无仓库不是错误,摘要为 null。
|
|
if noWarehouse := byID[2]; noWarehouse.Warehouse != nil {
|
|
t.Fatalf("无仓库公司应返回空摘要,实际 %+v", noWarehouse.Warehouse)
|
|
}
|
|
// 仓库停用不是错误:仍返回该公司自己的仓库,状态为停用,不回退默认公司仓库。
|
|
disabled := byID[3]
|
|
if disabled.Warehouse == nil || disabled.Warehouse.ID != 30 || disabled.Warehouse.Status != "inactive" ||
|
|
disabled.Warehouse.Location == nil || disabled.Warehouse.Location.ID != 31 {
|
|
t.Fatalf("停用仓库摘要错误 %+v", disabled.Warehouse)
|
|
}
|
|
}
|
|
|
|
// An audit failure must reach the caller so its business mutation can roll back;
|
|
// a rollback must remove both records, and reads must require an organization.
|
|
func TestOrganizationAuditTransactionAndScope(t *testing.T) {
|
|
db := isolationDB(t, false)
|
|
mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES(1,'actor','x'); INSERT INTO organizations(id,code,name) VALUES(2,'TWO','Two')`)
|
|
repo := NewOrganizationAuditRepository()
|
|
org1, org2 := int64(1), int64(2)
|
|
entry := &model.OrganizationAuditLog{ActorUserID: 1, OrganizationID: &org1, Action: "organization.switch", OldValue: json.RawMessage(`{"organization_id":2}`), NewValue: json.RawMessage(`{"organization_id":1}`)}
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
mustSQL(t, db, `UPDATE organizations SET name='Before' WHERE id=1`)
|
|
if _, err = tx.Exec(`UPDATE organizations SET name='Rolled back' WHERE id=1`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = repo.CreateTx(tx, entry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = tx.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logs, err := repo.ListByOrganization(1, 20, 0)
|
|
if err != nil || len(logs) != 0 {
|
|
t.Fatalf("rolled back audit=%+v err=%v", logs, err)
|
|
}
|
|
var organizationName string
|
|
if err = db.QueryRow(`SELECT name FROM organizations WHERE id=1`).Scan(&organizationName); err != nil || organizationName != "Before" {
|
|
t.Fatalf("business rollback=%s err=%v", organizationName, err)
|
|
}
|
|
tx, err = db.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.Exec(`UPDATE organizations SET name='Audit failed' WHERE id=1`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entry.NewValue = json.RawMessage(`not-json`)
|
|
if err = repo.CreateTx(tx, entry); err == nil {
|
|
t.Fatal("invalid audit JSON was accepted")
|
|
}
|
|
tx.Rollback()
|
|
if err = db.QueryRow(`SELECT name FROM organizations WHERE id=1`).Scan(&organizationName); err != nil || organizationName != "Before" {
|
|
t.Fatalf("audit failure business rollback=%s err=%v", organizationName, err)
|
|
}
|
|
entry.NewValue = json.RawMessage(`{"organization_id":1}`)
|
|
tx, err = db.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err = tx.Exec(`UPDATE organizations SET name='Committed' WHERE id=1`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = repo.CreateTx(tx, entry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = db.QueryRow(`SELECT name FROM organizations WHERE id=1`).Scan(&organizationName); err != nil || organizationName != "Committed" {
|
|
t.Fatalf("business commit=%s err=%v", organizationName, err)
|
|
}
|
|
entry.OrganizationID = &org2
|
|
entry.Action = "organization.grants.update"
|
|
entry.OldValue = json.RawMessage(`{"organization_ids":[1],"default_organization_id":1}`)
|
|
entry.NewValue = json.RawMessage(`{"organization_ids":[2],"default_organization_id":2}`)
|
|
if err = repo.Create(entry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entry.OrganizationID = nil
|
|
entry.Action = "organization.grants.update"
|
|
if err = repo.Create(entry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logs, err = repo.ListByOrganization(1, 20, 0)
|
|
if err != nil || len(logs) != 1 || logs[0].Action != "organization.switch" {
|
|
t.Fatalf("scoped audit=%+v err=%v", logs, err)
|
|
}
|
|
var newValue struct {
|
|
OrganizationID int64 `json:"organization_id"`
|
|
}
|
|
if err = json.Unmarshal(logs[0].NewValue, &newValue); err != nil || newValue.OrganizationID != 1 {
|
|
t.Fatalf("audit value=%s err=%v", logs[0].NewValue, err)
|
|
}
|
|
if _, err = repo.ListByOrganization(0, 20, 0); err == nil {
|
|
t.Fatal("unscoped audit read accepted")
|
|
}
|
|
if err = repo.CreateTx(nil, entry); err == nil {
|
|
t.Fatal("nil transaction accepted")
|
|
}
|
|
}
|
|
|
|
func TestOrganizationRepositoryPropagatesDatabaseFailures(t *testing.T) {
|
|
db := isolationDB(t, false)
|
|
if err := db.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := NewOrganizationRepository()
|
|
if _, err := repo.ListAccessible(1); err == nil {
|
|
t.Fatal("ListAccessible swallowed database failure")
|
|
}
|
|
if allowed, err := repo.CanAccess(1, 1); err == nil || allowed {
|
|
t.Fatalf("CanAccess allowed=%v err=%v", allowed, err)
|
|
}
|
|
audit := NewOrganizationAuditRepository()
|
|
if err := audit.Create(&model.OrganizationAuditLog{ActorUserID: 1, Action: "test"}); err == nil {
|
|
t.Fatal("Create swallowed database failure")
|
|
}
|
|
if _, err := audit.ListByOrganization(1, 20, 0); err == nil {
|
|
t.Fatal("audit read swallowed database failure")
|
|
}
|
|
}
|