package handler import ( "database/sql" "encoding/json" "fmt" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/testutil" "golang.org/x/crypto/bcrypt" ) // These tests add real route/auth/organization-boundary coverage. Existing // repository/service suites own inventory concurrency and insufficient-stock // rollback; this suite adds concurrent approval and mid-transaction failures. // Do not parallelize: testutil swaps the process-global database.DB. type companyHTTPFixture struct { db *sql.DB router *gin.Engine tokens map[string]string } func companySQL(t *testing.T, db *sql.DB, query string, args ...interface{}) { t.Helper() if _, err := db.Exec(query, args...); err != nil { t.Fatalf("fixture SQL: %v", err) } } func newCompanyHTTPFixture(t *testing.T) *companyHTTPFixture { t.Helper() // Requires TEST_DB_DSN with company_isolation_test prefix; creates and drops // only a generated disposable schema. An unset DSN is a skip, not acceptance. db := testutil.NewIsolationDB(t) companySQL(t, db, `INSERT INTO organizations(id,code,name) VALUES(301,'HTTP_BJ','北京公司'),(302,'HTTP_WH','武汉公司'); INSERT INTO users(id,username,password) VALUES(301,'http-beijing','x'),(302,'http-wuhan','x'),(303,'http-both','x'),(304,'http-admin','x'); INSERT INTO user_roles(user_id,role_id) SELECT u.id,r.id FROM users u CROSS JOIN roles r WHERE u.id IN(301,302,303) AND r.code='warehouse_admin'; INSERT INTO user_roles(user_id,role_id) SELECT 304,id FROM roles WHERE code='super_admin'; INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES(301,301,1),(302,302,1),(303,301,1),(303,302,0); INSERT INTO material_categories(id,organization_id,code,name) VALUES(301,301,'BJ','北京机械'),(302,302,'WH','武汉电气'); INSERT INTO material_categories(id,organization_id,code,name,parent_id) VALUES(311,301,'BJ01','北京标准件',301),(312,302,'WH01','武汉元器件',302); INSERT INTO units(id,organization_id,code,name) VALUES(301,301,'PCS','件'),(302,302,'KG','千克'); INSERT INTO code_rules(id,organization_id,category_id,prefix,sequence_length,next_sequence) VALUES(301,301,311,'BJ',4,1),(302,302,312,'WH',4,1); INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES(301,301,'SHARED','北京同编码物料','','件',311,301),(302,302,'SHARED','武汉同编码物料','','千克',312,302); INSERT INTO warehouses(id,organization_id,code,name) VALUES(301,301,'BJ_W','北京仓库'),(302,302,'WH_W','武汉仓库'); INSERT INTO warehouse_locations(id,warehouse_id,code,name) VALUES(301,301,'BJ_L','北京库位'),(302,302,'WH_L','武汉库位');`) password, err := bcrypt.GenerateFromPassword([]byte("CompanyTest123!"), bcrypt.MinCost) if err != nil { t.Fatal(err) } companySQL(t, db, `UPDATE users SET password=? WHERE id BETWEEN 301 AND 304`, string(password)) gin.SetMode(gin.TestMode) f := &companyHTTPFixture{db: db, router: gin.New(), tokens: map[string]string{}} NewHandler().RegisterRoutes(f.router) for _, name := range []string{"beijing", "wuhan", "both", "admin"} { var login struct { Token string `json:"token"` } data := f.request(t, "", "", "POST", "/api/auth/login", fmt.Sprintf(`{"username":"http-%s","password":"CompanyTest123!"}`, name), 200) companyDecode(t, data, &login) if login.Token == "" { t.Fatalf("login %s returned no token", name) } f.tokens[name] = login.Token } return f } func (f *companyHTTPFixture) serve(account, org, method, path, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) if account != "" { req.Header.Set("Authorization", "Bearer "+f.tokens[account]) } if org != "" { req.Header.Set("X-Organization-ID", org) } if body != "" { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() f.router.ServeHTTP(w, req) return w } func (f *companyHTTPFixture) request(t *testing.T, account, org, method, path, body string, want int) json.RawMessage { t.Helper() w := f.serve(account, org, method, path, body) if w.Code != want { t.Fatalf("%s %s account=%s org=%s: status=%d want=%d body=%s", method, path, account, org, w.Code, want, w.Body.String()) } var envelope struct { Code int `json:"code"` Data json.RawMessage `json:"data"` } companyDecode(t, w.Body.Bytes(), &envelope) if want < 300 && envelope.Code != 0 { t.Fatalf("business error: %s", w.Body.String()) } return envelope.Data } func companyDecode(t *testing.T, data []byte, value interface{}) { t.Helper() if err := json.Unmarshal(data, value); err != nil { t.Fatalf("decode %s: %v", data, err) } } type companyPage[T any] struct { Items []T `json:"items"` Total int64 `json:"total"` HasMore bool `json:"has_more"` } func companyGetPage[T any](t *testing.T, f *companyHTTPFixture, account string, org int64, path string, total int64) companyPage[T] { t.Helper() var page companyPage[T] companyDecode(t, f.request(t, account, fmt.Sprint(org), "GET", path, "", 200), &page) if page.Total != total { t.Fatalf("%s org=%d total=%d want=%d items=%+v", path, org, page.Total, total, page.Items) } return page } func TestCompanyIsolationAuthorizationMySQL(t *testing.T) { f := newCompanyHTTPFixture(t) for _, tc := range []struct { account string org int64 want int }{ {"beijing", 301, 200}, {"beijing", 302, 403}, {"wuhan", 301, 403}, {"wuhan", 302, 200}, {"both", 301, 200}, {"both", 302, 200}, {"admin", 301, 200}, {"admin", 302, 200}, {"admin", 999999, 403}, } { t.Run(fmt.Sprintf("%s/%d", tc.account, tc.org), func(t *testing.T) { f.request(t, tc.account, fmt.Sprint(tc.org), "GET", "/api/materials", "", tc.want) }) } for _, header := range []string{"", "0", "-1", "abc", "301.5"} { t.Run("invalid-header/"+header, func(t *testing.T) { f.request(t, "admin", header, "GET", "/api/units", "", 400) }) } f.request(t, "", "301", "GET", "/api/materials", "", 401) // Organization grants do not grant admin configuration or approval roles. f.request(t, "both", "301", "POST", "/api/units", `{"code":"NEW","name":"新单位"}`, 403) f.request(t, "both", "301", "GET", "/api/applications", "", 401) for _, tc := range []struct { account string ids []int64 def int64 }{ {"beijing", []int64{301}, 301}, {"wuhan", []int64{302}, 302}, {"both", []int64{301, 302}, 301}, {"admin", []int64{1, 301, 302}, 0}, } { var access model.OrganizationAccess companyDecode(t, f.request(t, tc.account, "", "GET", "/api/auth/organizations", "", 200), &access) if len(access.Organizations) != len(tc.ids) { t.Fatalf("%s access=%+v", tc.account, access) } for _, want := range tc.ids { found := false for _, org := range access.Organizations { found = found || org.ID == want } if !found { t.Fatalf("%s missing company %d", tc.account, want) } } if tc.def != 0 && (access.DefaultOrganizationID == nil || *access.DefaultOrganizationID != tc.def) { t.Fatalf("%s default=%v", tc.account, access.DefaultOrganizationID) } } for _, target := range []int64{302, 301} { f.request(t, "both", "", "POST", "/api/auth/organization-context", fmt.Sprintf(`{"organization_id":%d,"previous_organization_id":%d}`, target, 603-target), 200) } var audits, defaults int if err := f.db.QueryRow(`SELECT COUNT(*) FROM organization_audit_logs WHERE actor_user_id=303`).Scan(&audits); err != nil { t.Fatal(err) } if err := f.db.QueryRow(`SELECT COUNT(*) FROM user_organizations WHERE user_id=303 AND organization_id=301 AND is_default=1`).Scan(&defaults); err != nil || defaults != 1 || audits != 2 { t.Fatalf("switch audits=%d default=%d err=%v", audits, defaults, err) } // Database revocation and stale JWT roles remain in the existing context suite. } func TestCompanyIsolationMasterDataMySQL(t *testing.T) { f := newCompanyHTTPFixture(t) for _, org := range []int64{301, 302} { for _, tc := range []struct { path string want int }{{"/api/material-categories", 2}, {"/api/units", 1}, {"/api/code-rules", 1}} { var items []struct { OrganizationID int64 `json:"organization_id"` } companyDecode(t, f.request(t, "both", fmt.Sprint(org), "GET", tc.path, "", 200), &items) if len(items) != tc.want { t.Fatalf("%s count=%d want=%d", tc.path, len(items), tc.want) } for _, item := range items { if item.OrganizationID != org { t.Fatalf("%s leaked %+v", tc.path, item) } } } page := companyGetPage[model.Material](t, f, "both", org, "/api/materials?search=SHARED&limit=1", 1) if len(page.Items) != 1 || page.Items[0].ID != org || page.Items[0].OrganizationID != org || page.HasMore { t.Fatalf("same-code search org=%d: %+v", org, page) } var detail model.Material companyDecode(t, f.request(t, "both", fmt.Sprint(org), "GET", fmt.Sprintf("/api/materials/%d", org), "", 200), &detail) if detail.CategoryID == nil || *detail.CategoryID != org+10 || detail.BaseUnitID == nil || *detail.BaseUnitID != org { t.Fatalf("references=%+v", detail) } f.request(t, "both", fmt.Sprint(org), "GET", fmt.Sprintf("/api/materials/%d", 603-org), "", 404) } preview := func(org int64, code string) { t.Helper() var next model.NextMaterialCode companyDecode(t, f.request(t, "both", fmt.Sprint(org), "GET", fmt.Sprintf("/api/code-rules/next?category_id=%d", org+10), "", 200), &next) if !next.Available || next.Code != code { t.Fatalf("org=%d preview=%+v want=%s", org, next, code) } } create := func(org int64, code string) model.Material { t.Helper() var m model.Material body := fmt.Sprintf(`{"organization_id":%d,"material_code":%q,"name":"路由建档","category_id":%d,"base_unit_id":%d}`, 603-org, code, org+10, org) companyDecode(t, f.request(t, "both", fmt.Sprint(org), "POST", "/api/materials", body, 201), &m) if m.OrganizationID != org { t.Fatalf("forged ownership=%+v", m) } return m } preview(301, "BJ0001") preview(302, "WH0001") a := create(301, "BJ0001") preview(301, "BJ0002") preview(302, "WH0001") create(302, "WH0001") preview(302, "WH0002") create(301, "SHARED-NEW") create(302, "SHARED-NEW") f.request(t, "both", "301", "POST", "/api/materials", `{"material_code":"SHARED-NEW","name":"重复","category_id":311,"base_unit_id":301}`, 400) page := companyGetPage[model.Material](t, f, "both", 301, "/api/materials?limit=1", 3) if len(page.Items) != 1 || !page.HasMore { t.Fatalf("page=%+v", page) } page = companyGetPage[model.Material](t, f, "both", 301, "/api/materials?limit=1&offset=3", 3) if len(page.Items) != 0 || page.HasMore { t.Fatalf("last page=%+v", page) } companyGetPage[model.Material](t, f, "both", 301, "/api/materials?search=武汉", 0) for _, tc := range []struct{ path, body string }{ {"/api/materials", `{"material_code":"BJ0002","name":"坏分类","category_id":312,"base_unit_id":301}`}, {"/api/materials", `{"material_code":"BJ0002","name":"坏单位","category_id":311,"base_unit_id":302}`}, {"/api/material-categories", `{"code":"BAD","name":"坏父级","parent_id":302}`}, {"/api/code-rules", `{"category_id":312,"prefix":"BAD","sequence_length":4}`}, } { f.request(t, "admin", "301", "POST", tc.path, tc.body, 400) } preview(301, "BJ0002") preview(302, "WH0002") companyGetPage[model.Material](t, f, "both", 301, "/api/materials", 3) for _, body := range []string{`{"category_id":312,"name":"坏分类"}`, `{"category_id":311,"base_unit_id":302,"name":"坏单位"}`} { f.request(t, "both", "301", "PUT", fmt.Sprintf("/api/materials/%d", a.ID), body, 400) } for _, tc := range []struct{ method, suffix, body string }{{"PUT", "", `{"category_id":311,"name":"恶意修改"}`}, {"POST", "/obsolete", ""}, {"POST", "/restore", ""}} { f.request(t, "both", "301", tc.method, "/api/materials/302"+tc.suffix, tc.body, 404) } f.request(t, "both", "301", "PUT", fmt.Sprintf("/api/materials/%d", a.ID), `{"organization_id":302,"category_id":311,"base_unit_id":301,"name":"北京更新"}`, 200) f.request(t, "both", "301", "POST", fmt.Sprintf("/api/materials/%d/obsolete", a.ID), "", 200) companyGetPage[model.Material](t, f, "both", 301, "/api/materials?status=obsolete", 1) companyGetPage[model.Material](t, f, "both", 302, "/api/materials?status=obsolete", 0) f.request(t, "both", "301", "POST", fmt.Sprintf("/api/materials/%d/restore", a.ID), "", 200) var updated model.Material companyDecode(t, f.request(t, "both", "301", "GET", fmt.Sprintf("/api/materials/%d", a.ID), "", 200), &updated) if updated.OrganizationID != 301 || updated.Name != "北京更新" || updated.Status != "active" || updated.Version != 4 { t.Fatalf("CRUD=%+v", updated) } var logs []model.MaterialLog companyDecode(t, f.request(t, "both", "301", "GET", fmt.Sprintf("/api/logs/materials/%d", a.ID), "", 200), &logs) if len(logs) != 4 { t.Fatalf("CRUD logs=%+v", logs) } for _, log := range logs { if log.MaterialID != a.ID { t.Fatalf("foreign log=%+v", log) } } companyGetPage[model.MaterialLog](t, f, "both", 301, "/api/logs?material_id=302", 0) f.request(t, "both", "301", "GET", "/api/logs/materials/302", "", 404) companyGetPage[model.MaterialLog](t, f, "both", 301, "/api/logs?limit=1", 5) companyGetPage[model.MaterialLog](t, f, "both", 302, "/api/logs", 2) // Configuration CRUD also takes ownership from the selected company. for _, org := range []int64{301, 302} { header := fmt.Sprint(org) var category model.MaterialCategory companyDecode(t, f.request(t, "admin", header, "POST", "/api/material-categories", fmt.Sprintf(`{"organization_id":%d,"code":"EXTRA","name":"新增小类","parent_id":%d}`, 603-org, org), 201), &category) f.request(t, "admin", header, "PUT", fmt.Sprintf("/api/material-categories/%d", category.ID), fmt.Sprintf(`{"organization_id":%d,"name":"更新小类","parent_id":%d}`, 603-org, org), 200) var unit model.Unit companyDecode(t, f.request(t, "admin", header, "POST", "/api/units", fmt.Sprintf(`{"organization_id":%d,"code":"EXTRA","name":"新增单位"}`, 603-org), 201), &unit) // A no-op update is valid; MySQL must count the matched row as production does. f.request(t, "admin", header, "PUT", fmt.Sprintf("/api/units/%d", unit.ID), `{"name":"新增单位","precision_scale":0,"status":"active"}`, 200) f.request(t, "admin", header, "PUT", fmt.Sprintf("/api/units/%d", unit.ID), fmt.Sprintf(`{"organization_id":%d,"name":"更新单位","precision_scale":2}`, 603-org), 200) var rule model.CodeRule companyDecode(t, f.request(t, "admin", header, "POST", "/api/code-rules", fmt.Sprintf(`{"organization_id":%d,"category_id":%d,"prefix":"EX","sequence_length":3}`, 603-org, category.ID), 201), &rule) f.request(t, "admin", header, "PUT", fmt.Sprintf("/api/code-rules/%d", rule.ID), fmt.Sprintf(`{"organization_id":%d,"category_id":%d,"prefix":"EX","sequence_length":3,"next_sequence":9}`, 603-org, category.ID), 200) for _, tc := range []struct { table string id int64 }{ {"material_categories", category.ID}, {"units", unit.ID}, {"code_rules", rule.ID}, } { var owner int64 if err := f.db.QueryRow("SELECT organization_id FROM "+tc.table+" WHERE id=?", tc.id).Scan(&owner); err != nil || owner != org { t.Fatalf("%s id=%d owner=%d want=%d err=%v", tc.table, tc.id, owner, org, err) } } var savedRule model.NextMaterialCode companyDecode(t, f.request(t, "both", header, "GET", fmt.Sprintf("/api/code-rules/next?category_id=%d", category.ID), "", 200), &savedRule) if !savedRule.Available || savedRule.Code != "EX009" { t.Fatalf("saved rule=%+v", savedRule) } } for _, tc := range []struct{ path, body string }{ {"/api/material-categories/312", `{"name":"跨公司","parent_id":301}`}, {"/api/units/302", `{"name":"跨公司"}`}, {"/api/code-rules/302", `{"category_id":311,"prefix":"HACK","sequence_length":4}`}, } { f.request(t, "admin", "301", "PUT", tc.path, tc.body, 404) } } func TestCompanyIsolationWarehouseApplicationFlowMySQL(t *testing.T) { f := newCompanyHTTPFixture(t) type flowIDs struct{ inbound, outbound, application int64 } flows := map[int64]flowIDs{} for _, org := range []int64{301, 302} { account, received, requested := "beijing", 10, 3 if org == 302 { account, received, requested = "wuhan", 20, 4 } header := fmt.Sprint(org) var in model.InboundOrder body := fmt.Sprintf(`{"organization_id":%d,"warehouse_id":%d,"location_id":%d,"handler":"经办","supplier":"供应商","items":[{"material_id":%d,"quantity":%d}]}`, 603-org, 603-org, 603-org, org, received) companyDecode(t, f.request(t, account, header, "POST", "/api/inbound", body, 201), &in) if in.OrganizationID != org || in.WarehouseID != org || in.LocationID != org { t.Fatalf("inbound scope=%+v", in) } var app model.MaterialApplication body = fmt.Sprintf(`{"organization_id":%d,"purpose":"领用","items":[{"material_id":%d,"quantity":%d}]}`, 603-org, org, requested) companyDecode(t, f.request(t, account, header, "POST", "/api/applications", body, 201), &app) if app.OrganizationID != org { t.Fatalf("application scope=%+v", app) } f.request(t, account, header, "POST", fmt.Sprintf("/api/applications/%d/approve", app.ID), `{"auto_outbound":true}`, 401) f.request(t, "admin", header, "POST", fmt.Sprintf("/api/applications/%d/approve", app.ID), `{"auto_outbound":true}`, 200) page := companyGetPage[model.OutboundOrder](t, f, account, org, "/api/outbound?limit=1", 1) if len(page.Items) != 1 || page.Items[0].OrganizationID != org || page.Items[0].WarehouseID != org || page.Items[0].LocationID != org { t.Fatalf("auto outbound=%+v", page) } var detail model.OutboundOrder companyDecode(t, f.request(t, account, header, "GET", fmt.Sprintf("/api/outbound/%d", page.Items[0].ID), "", 200), &detail) if len(detail.Items) != 1 || detail.Items[0].MaterialID != org || detail.Items[0].Quantity != requested { t.Fatalf("auto outbound items=%+v", detail) } flows[org] = flowIDs{in.ID, page.Items[0].ID, app.ID} stocks := companyGetPage[model.Stock](t, f, account, org, "/api/stock?search=SHARED&limit=1", 1) if len(stocks.Items) != 1 || stocks.Items[0].MaterialID != org || stocks.Items[0].TotalOnHand.String() != fmt.Sprint(received-requested) { t.Fatalf("stock=%+v want=%d", stocks, received-requested) } companyGetPage[model.InboundOrder](t, f, account, org, "/api/inbound", 1) companyGetPage[model.MaterialApplication](t, f, account, org, "/api/applications/my?status=approved", 1) companyGetPage[model.MaterialApplication](t, f, "both", org, "/api/applications/my", 0) companyGetPage[model.MaterialApplication](t, f, "admin", org, "/api/applications?status=approved", 1) f.request(t, account, header, "PUT", fmt.Sprintf("/api/stock/%d/warning", org), `{"warning_stock":100}`, 200) companyGetPage[model.Stock](t, f, account, org, "/api/stock/warning", 1) logs := companyGetPage[model.StockLogResponse](t, f, account, org, "/api/stock/logs?limit=1", 2) if len(logs.Items) != 1 || logs.Items[0].MaterialID != org || !logs.HasMore { t.Fatalf("stock logs=%+v", logs) } companyGetPage[model.StockLogResponse](t, f, account, org, fmt.Sprintf("/api/stock/logs/%d", org), 2) var dimensions []model.InventoryBalanceDetail companyDecode(t, f.request(t, account, header, "GET", fmt.Sprintf("/api/stock/%d/details", org), "", 200), &dimensions) if len(dimensions) != 1 || dimensions[0].OrganizationID != org || dimensions[0].WarehouseID != org || dimensions[0].LocationID != org { t.Fatalf("inventory dimensions=%+v", dimensions) } category := "BJ" if org == 302 { category = "WH" } companyGetPage[model.Stock](t, f, account, org, "/api/stock?category="+category, 1) companyGetPage[model.Stock](t, f, account, org, "/api/stock?category=OTHER", 0) var mismatches int if err := f.db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id=? AND (warehouse_id<>? OR location_id<>? OR material_id<>?)`, org, org, org, org).Scan(&mismatches); err != nil || mismatches != 0 { t.Fatalf("stock dimensions mismatch=%d err=%v", mismatches, err) } } for _, org := range []int64{301, 302} { other := 603 - org ids := flows[other] for _, path := range []string{ fmt.Sprintf("/api/materials/%d", other), fmt.Sprintf("/api/stock/%d", other), fmt.Sprintf("/api/stock/%d/details", other), fmt.Sprintf("/api/stock/logs/%d", other), fmt.Sprintf("/api/inbound/%d", ids.inbound), fmt.Sprintf("/api/outbound/%d", ids.outbound), fmt.Sprintf("/api/inbound/materials/%d", other), fmt.Sprintf("/api/outbound/materials/%d", other), } { f.request(t, "both", fmt.Sprint(org), "GET", path, "", 404) } f.request(t, "both", fmt.Sprint(org), "PUT", fmt.Sprintf("/api/stock/%d/warning", other), `{"warning_stock":99}`, 404) for _, suffix := range []string{"approve", "reject", "cancel"} { f.request(t, "admin", fmt.Sprint(org), "POST", fmt.Sprintf("/api/applications/%d/%s", ids.application, suffix), `{"auto_outbound":true,"reason":"跨公司"}`, 404) } for _, kind := range []string{"inbound", "outbound"} { id := ids.inbound if kind == "outbound" { id = ids.outbound } for _, method := range []string{"PUT", "DELETE"} { body := "" if method == "PUT" { body = fmt.Sprintf(`{"handler":"h","supplier":"s","receiver":"r","items":[{"material_id":%d,"quantity":1}]}`, org) } f.request(t, "both", fmt.Sprint(org), method, fmt.Sprintf("/api/%s/%d", kind, id), body, 404) } } f.request(t, "both", fmt.Sprint(org), "POST", "/api/applications", fmt.Sprintf(`{"items":[{"material_id":%d,"quantity":1}]}`, other), 400) for _, action := range []string{"cancel", "reject"} { var app model.MaterialApplication companyDecode(t, f.request(t, "both", fmt.Sprint(org), "POST", "/api/applications", fmt.Sprintf(`{"items":[{"material_id":%d,"quantity":1}]}`, org), 201), &app) account := "both" if action == "reject" { account = "admin" } f.request(t, account, fmt.Sprint(org), "POST", fmt.Sprintf("/api/applications/%d/%s", app.ID, action), `{"reason":"不需要"}`, 200) status := "cancelled" if action == "reject" { status = "rejected" } companyGetPage[model.MaterialApplication](t, f, "both", org, "/api/applications/my?status="+status, 1) } var balance string want := "7.000000" if org == 302 { want = "16.000000" } if err := f.db.QueryRow(`SELECT on_hand FROM inventory_balances WHERE organization_id=? AND warehouse_id=? AND location_id=? AND material_id=?`, org, org, org, org).Scan(&balance); err != nil || balance != want { t.Fatalf("final org=%d balance=%s want=%s err=%v", org, balance, want, err) } companyGetPage[model.OutboundOrder](t, f, "both", org, "/api/outbound", 1) // Existing order_flow integration owns inbound update/delete. Exercise the // corresponding manual outbound reversal through the real HTTP route. var manual model.OutboundOrder companyDecode(t, f.request(t, "both", fmt.Sprint(org), "POST", "/api/outbound", fmt.Sprintf(`{"receiver":"r","items":[{"material_id":%d,"quantity":1}]}`, org), 201), &manual) f.request(t, "both", fmt.Sprint(org), "PUT", fmt.Sprintf("/api/outbound/%d", manual.ID), fmt.Sprintf(`{"receiver":"r","items":[{"material_id":%d,"quantity":2}]}`, org), 200) f.request(t, "both", fmt.Sprint(org), "DELETE", fmt.Sprintf("/api/outbound/%d", manual.ID), "", 200) if err := f.db.QueryRow(`SELECT on_hand FROM inventory_balances WHERE organization_id=? AND material_id=?`, org, org).Scan(&balance); err != nil || balance != want { t.Fatalf("outbound reversal org=%d balance=%s want=%s err=%v", org, balance, want, err) } companyGetPage[model.OutboundOrder](t, f, "both", org, "/api/outbound", 1) for _, state := range []struct{ setup, restore string }{ {"UPDATE warehouses SET status='inactive' WHERE id=?", "UPDATE warehouses SET status='active' WHERE id=?"}, {"UPDATE warehouse_locations SET status='inactive' WHERE id=?", "UPDATE warehouse_locations SET status='active' WHERE id=?"}, } { companySQL(t, f.db, state.setup, org) // Disabled dimensions still permit reading existing stock/documents. companyGetPage[model.Stock](t, f, "both", org, "/api/stock", 1) companyGetPage[model.InboundOrder](t, f, "both", org, "/api/inbound", 1) companyGetPage[model.OutboundOrder](t, f, "both", org, "/api/outbound", 1) f.request(t, "both", fmt.Sprint(org), "GET", fmt.Sprintf("/api/stock/%d/details", org), "", 200) own := flows[org] for _, req := range []struct{ method, path, body string }{ {"PUT", fmt.Sprintf("/api/inbound/%d", own.inbound), fmt.Sprintf(`{"handler":"h","supplier":"s","items":[{"material_id":%d,"quantity":10}]}`, org)}, {"DELETE", fmt.Sprintf("/api/inbound/%d", own.inbound), ""}, {"PUT", fmt.Sprintf("/api/outbound/%d", own.outbound), fmt.Sprintf(`{"receiver":"r","items":[{"material_id":%d,"quantity":1}]}`, org)}, {"DELETE", fmt.Sprintf("/api/outbound/%d", own.outbound), ""}, } { f.request(t, "both", fmt.Sprint(org), req.method, req.path, req.body, 409) } companySQL(t, f.db, state.restore, org) } } } func TestCompanyIsolationInventoryConfigurationMySQL(t *testing.T) { f := newCompanyHTTPFixture(t) companySQL(t, f.db, `DELETE FROM warehouse_locations WHERE id=301; DELETE FROM warehouses WHERE id=301`) for _, tc := range []struct{ name, setup, message string }{ {"no warehouse", "", "请先配置本公司仓库"}, {"no location", `INSERT INTO warehouses(id,organization_id,code,name) VALUES(301,301,'BJ_W','北京仓库')`, "请先配置本公司仓库的库位"}, {"inactive warehouse", `INSERT INTO warehouse_locations(id,warehouse_id,code,name) VALUES(301,301,'BJ_L','北京库位'); UPDATE warehouses SET status='inactive' WHERE id=301`, "本公司仓库已停用"}, {"inactive location", `UPDATE warehouses SET status='active' WHERE id=301; UPDATE warehouse_locations SET status='inactive' WHERE id=301`, "本公司库位已停用"}, } { t.Run(tc.name, func(t *testing.T) { if tc.setup != "" { companySQL(t, f.db, tc.setup) } companyGetPage[model.Stock](t, f, "beijing", 301, "/api/stock", 0) f.request(t, "beijing", "301", "GET", "/api/materials/301", "", 200) var app model.MaterialApplication companyDecode(t, f.request(t, "beijing", "301", "POST", "/api/applications", `{"items":[{"material_id":301,"quantity":1}]}`, 201), &app) for _, req := range []struct{ method, path, body string }{ {"POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":301,"quantity":1}]}`}, {"POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":301,"quantity":1}]}`}, {"PUT", "/api/stock/301/warning", `{"warning_stock":1}`}, {"POST", fmt.Sprintf("/api/applications/%d/approve", app.ID), `{"auto_outbound":true}`}, } { w := f.serve("admin", "301", req.method, req.path, req.body) if w.Code != 409 || !strings.Contains(w.Body.String(), tc.message) { t.Fatalf("%s %s status=%d body=%s want409/%s", req.method, req.path, w.Code, w.Body.String(), tc.message) } } // Ordinary approval does not require active stock dimensions. f.request(t, "admin", "301", "POST", fmt.Sprintf("/api/applications/%d/approve", app.ID), `{"auto_outbound":false}`, 200) companyGetPage[model.OutboundOrder](t, f, "beijing", 301, "/api/outbound", 0) }) } var writes int if err := f.db.QueryRow(`SELECT (SELECT COUNT(*) FROM inventory_balances WHERE organization_id=301)+(SELECT COUNT(*) FROM inbound_orders WHERE organization_id=301)+(SELECT COUNT(*) FROM outbound_orders WHERE organization_id=301)+(SELECT COUNT(*) FROM stock_logs WHERE organization_id=301)`).Scan(&writes); err != nil || writes != 0 { t.Fatalf("configuration failures left writes=%d err=%v", writes, err) } f.request(t, "wuhan", "302", "POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":302,"quantity":1}]}`, 201) } func TestCompanyIsolationApprovalConcurrencyAndFaultMySQL(t *testing.T) { f := newCompanyHTTPFixture(t) f.request(t, "both", "301", "POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":301,"quantity":10}]}`, 201) f.request(t, "both", "302", "POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":302,"quantity":20}]}`, 201) newApplication := func() int64 { t.Helper() var app model.MaterialApplication companyDecode(t, f.request(t, "both", "301", "POST", "/api/applications", `{"items":[{"material_id":301,"quantity":3}]}`, 201), &app) return app.ID } assertState := func(id int64, status, balance string, outbounds, logs int) { t.Helper() var gotStatus, gotBalance, otherBalance string var gotOrders, gotLogs int if err := f.db.QueryRow(`SELECT status FROM material_applications WHERE id=? AND organization_id=301`, id).Scan(&gotStatus); err != nil { t.Fatal(err) } if err := f.db.QueryRow(`SELECT on_hand FROM inventory_balances WHERE organization_id=301 AND material_id=301`).Scan(&gotBalance); err != nil { t.Fatal(err) } if err := f.db.QueryRow(`SELECT on_hand FROM inventory_balances WHERE organization_id=302 AND material_id=302`).Scan(&otherBalance); err != nil { t.Fatal(err) } if err := f.db.QueryRow(`SELECT COUNT(*) FROM outbound_orders WHERE organization_id=301`).Scan(&gotOrders); err != nil { t.Fatal(err) } if err := f.db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id=301`).Scan(&gotLogs); err != nil { t.Fatal(err) } if gotStatus != status || gotBalance != balance || otherBalance != "20.000000" || gotOrders != outbounds || gotLogs != logs { t.Fatalf("state=%s/%s/%s/%d/%d want=%s/%s/20/%d/%d", gotStatus, gotBalance, otherBalance, gotOrders, gotLogs, status, balance, outbounds, logs) } } id := newApplication() start := make(chan struct{}) results := make(chan *httptest.ResponseRecorder, 2) var wg sync.WaitGroup for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() <-start results <- f.serve("admin", "301", "POST", fmt.Sprintf("/api/applications/%d/approve", id), `{"auto_outbound":true}`) }() } close(start) wg.Wait() close(results) successes, conflicts := 0, 0 for w := range results { switch w.Code { case 200: successes++ case 400: conflicts++ default: t.Fatalf("concurrent approval status=%d body=%s", w.Code, w.Body.String()) } } if successes != 1 || conflicts != 1 { t.Fatalf("concurrent successes=%d conflicts=%d", successes, conflicts) } assertState(id, "approved", "7.000000", 1, 2) // Force failure after stock deduction and outbound insertion, at stock log // insertion. The transaction must restore all four artifacts. id = newApplication() companySQL(t, f.db, `CREATE TRIGGER fail_company_application_log BEFORE INSERT ON stock_logs FOR EACH ROW BEGIN IF NEW.organization_id=301 AND NEW.related_order_type='application' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='integration injected log failure'; END IF; END`) f.request(t, "admin", "301", "POST", fmt.Sprintf("/api/applications/%d/approve", id), `{"auto_outbound":true}`, 500) assertState(id, "pending", "7.000000", 1, 2) companySQL(t, f.db, `DROP TRIGGER fail_company_application_log`) f.request(t, "admin", "301", "POST", fmt.Sprintf("/api/applications/%d/approve", id), `{"auto_outbound":true}`, 200) assertState(id, "approved", "4.000000", 2, 3) } func TestCompanyIsolationWebSocketRevocationMySQL(t *testing.T) { f := newCompanyHTTPFixture(t) withTicketService(t, 30*time.Second) f.router.GET("/ws", HandleWebSocket) server := httptest.NewServer(f.router) t.Cleanup(server.Close) ticket := func(account, org string) string { t.Helper() var result struct { Ticket string `json:"ticket"` } companyDecode(t, f.request(t, account, org, "POST", "/api/auth/websocket-ticket", "", 200), &result) if result.Ticket == "" { t.Fatal("empty WebSocket ticket") } return result.Ticket } type socketResult struct { event StockUpdateEvent err error } connect := func(account, org string) <-chan socketResult { t.Helper() url := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?ticket=" + ticket(account, org) conn, response, err := websocket.DefaultDialer.Dial(url, nil) if err != nil { t.Fatalf("connect %s/%s: %v response=%v", account, org, err, response) } t.Cleanup(func() { conn.Close() }) results := make(chan socketResult, 8) go func() { for { var event StockUpdateEvent err := conn.ReadJSON(&event) results <- socketResult{event, err} if err != nil { return } } }() return results } expectEvent := func(results <-chan socketResult, org int64, order int64) { t.Helper() select { case result := <-results: if result.err != nil || result.event.OrganizationID != org || result.event.OrderID != order { t.Fatalf("event=%+v want=%d/%d", result, org, order) } case <-time.After(3 * time.Second): t.Fatalf("no event for company %d", org) } } expectClosed := func(results <-chan socketResult) { t.Helper() select { case result := <-results: if result.err == nil { t.Fatalf("revoked socket received event=%+v", result.event) } case <-time.After(3 * time.Second): t.Fatal("revoked socket remained open") } } beijing := connect("both", "301") wuhan := connect("wuhan", "302") admin := connect("admin", "302") disabledCompany := connect("beijing", "301") // Both organizations are connected concurrently through real TCP handshakes. GetHub().BroadcastStockUpdate(301, "inbound", 701, nil) GetHub().BroadcastStockUpdate(302, "inbound", 702, nil) expectEvent(beijing, 301, 701) expectEvent(wuhan, 302, 702) expectEvent(admin, 302, 702) expectEvent(disabledCompany, 301, 701) for _, results := range []<-chan socketResult{beijing, wuhan, admin, disabledCompany} { select { case extra := <-results: t.Fatalf("cross-company/extra event=%+v", extra) case <-time.After(50 * time.Millisecond): } } // Recheck the database at delivery, rather than trusting handshake grants. beforeGrantRevocation := ticket("both", "301") companySQL(t, f.db, `DELETE FROM user_organizations WHERE user_id=303 AND organization_id=301`) f.request(t, "", "", "GET", "/ws?ticket="+beforeGrantRevocation, "", 403) GetHub().BroadcastStockUpdate(301, "outbound", 703, nil) expectClosed(beijing) expectEvent(disabledCompany, 301, 703) f.request(t, "both", "301", "GET", "/api/materials", "", 403) beforeRoleRevocation := ticket("admin", "302") companySQL(t, f.db, `DELETE FROM user_roles WHERE user_id=304; INSERT INTO user_roles(user_id,role_id) SELECT 304,id FROM roles WHERE code='warehouse_admin'`) GetHub().BroadcastStockUpdate(302, "outbound", 704, nil) expectClosed(admin) expectEvent(wuhan, 302, 704) f.request(t, "", "", "GET", "/ws?ticket="+beforeRoleRevocation, "", 403) f.request(t, "admin", "302", "GET", "/api/materials", "", 403) companySQL(t, f.db, `UPDATE organizations SET status='inactive' WHERE id=301`) GetHub().BroadcastStockUpdate(301, "outbound", 706, nil) expectClosed(disabledCompany) f.request(t, "beijing", "301", "GET", "/api/materials", "", 403) // A ticket issued before logout cannot start a new connection, and the live // socket cannot receive the next event. The old token also fails HTTP auth. pending := ticket("wuhan", "302") f.request(t, "wuhan", "", "POST", "/api/auth/logout", "", 200) f.request(t, "wuhan", "302", "GET", "/api/materials", "", 401) f.request(t, "wuhan", "302", "POST", "/api/auth/websocket-ticket", "", 401) f.request(t, "", "", "GET", "/ws?ticket="+pending, "", 401) GetHub().BroadcastStockUpdate(302, "outbound", 705, nil) expectClosed(wuhan) var revoked int if err := f.db.QueryRow(`SELECT COUNT(*) FROM revoked_tokens WHERE user_id=302`).Scan(&revoked); err != nil || revoked != 1 { t.Fatalf("logout persisted revocations=%d err=%v", revoked, err) } oldToken := f.tokens["wuhan"] var login struct { Token string `json:"token"` } companyDecode(t, f.request(t, "", "", "POST", "/api/auth/login", `{"username":"http-wuhan","password":"CompanyTest123!"}`, 200), &login) if login.Token == "" || login.Token == oldToken { t.Fatal("new login did not create a distinct session") } f.tokens["wuhan"] = login.Token f.request(t, "wuhan", "302", "GET", "/api/materials", "", 200) }