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") } }