package classes import ( "fmt" "reflect" "testing" "git.golem15.com/golem15/sm-user-plugin/models" ) // TestMergedPermissions pins the port of PHP User::getMergedPermissions and // Winter's hasPermission: groups merge with "most positive wins", the user's // own values override the groups' whatever they are, a permission is held // only at exactly 1, and wildcards work as in Winter. func TestMergedPermissions(t *testing.T) { ctx := t.Context() db := throttleDB(t) type ask struct { code string want bool } cases := []struct { name string groups []string // permissions column of each group, as stored own string // users.permissions, as stored; "NULL" stores NULL merged map[string]int asks []ask }{ { name: "two groups, the higher value wins", groups: []string{`{"blog.post":-1,"blog.edit":0}`, `{"blog.post":1,"blog.edit":-1}`}, own: "NULL", merged: map[string]int{"blog.post": 1, "blog.edit": 0}, asks: []ask{{"blog.post", true}, {"blog.edit", false}, {"blog.other", false}}, }, { name: "the order of the groups does not matter", groups: []string{`{"blog.post":1}`, `{"blog.post":-1}`}, own: "NULL", merged: map[string]int{"blog.post": 1}, asks: []ask{{"blog.post", true}}, }, { name: "a user-level deny overrides a group's allow", groups: []string{`{"blog.post":1,"blog.edit":1}`}, own: `{"blog.post":-1}`, merged: map[string]int{"blog.post": -1, "blog.edit": 1}, asks: []ask{{"blog.post", false}, {"blog.edit", true}}, }, { // The port is not "deny wins". name: "a user-level allow overrides a group's deny", groups: []string{`{"blog.post":-1}`, `{"blog.post":-1}`}, own: `{"blog.post":1}`, merged: map[string]int{"blog.post": 1}, asks: []ask{{"blog.post", true}}, }, { name: "a code held only at 0 or -1 is not held", groups: []string{`{"blog.zero":0,"blog.denied":-1}`}, own: `{"blog.own_denied":-1}`, merged: map[string]int{"blog.zero": 0, "blog.denied": -1, "blog.own_denied": -1}, asks: []ask{{"blog.zero", false}, {"blog.denied", false}, {"blog.own_denied", false}}, }, { name: "a user with no groups has only their own permissions", groups: nil, own: `{"blog.post":1,"shop.buy":-1}`, merged: map[string]int{"blog.post": 1, "shop.buy": -1}, asks: []ask{{"blog.post", true}, {"shop.buy", false}}, }, { name: "a user with nothing stored holds nothing", groups: nil, own: "NULL", merged: map[string]int{}, asks: []ask{{"blog.post", false}, {"*", false}, {"blog.*", false}}, }, { name: "an asked code ending in an asterisk matches a stored code by prefix", groups: []string{`{"blog.post":1,"shop.buy":-1}`}, own: "NULL", merged: map[string]int{"blog.post": 1, "shop.buy": -1}, asks: []ask{ {"blog.*", true}, {"shop.*", false}, // the only match is denied {"blog.post*", false}, // the prefix must not equal the stored code {"news.*", false}, }, }, { name: "an asked code starting with an asterisk matches a stored code by suffix", groups: []string{`{"blog.post":1,"shop.post":-1,"shop.buy":-1}`}, own: "NULL", merged: map[string]int{"blog.post": 1, "shop.post": -1, "shop.buy": -1}, asks: []ask{{"*.post", true}, {"*.buy", false}, {"*blog.post", false}}, }, { name: "a stored code ending in an asterisk grants the codes under it", groups: []string{`{"blog.*":1,"shop.*":-1}`}, own: `{"news.*":0}`, merged: map[string]int{"blog.*": 1, "shop.*": -1, "news.*": 0}, asks: []ask{ {"blog.post", true}, {"blog.", false}, // the stored prefix itself is not under it {"shop.buy", false}, {"news.read", false}, }, }, { // Winter treats a leading asterisk as a wildcard only on the asked // code, never on a stored one. Asking "*.post" still matches the // stored "*.post" as a longer code with that suffix. name: "a stored code starting with an asterisk is not a wildcard", groups: []string{`{"*.post":1}`}, own: "NULL", merged: map[string]int{"*.post": 1}, asks: []ask{{"blog.post", false}, {"*.post", true}}, }, { name: "stored shapes are read tolerantly", groups: []string{``, `[]`, `{"blog.post":"1","blog.bad":"yes","blog.edit":"-1"}`}, own: ``, merged: map[string]int{"blog.post": 1, "blog.edit": -1}, asks: []ask{{"blog.post", true}, {"blog.bad", false}, {"blog.edit", false}}, }, { name: "several groups: the highest value of each code wins", groups: []string{ `{"blog.post":-1,"blog.edit":1,"shop.buy":0}`, `{"blog.post":0,"blog.edit":-1,"news.read":1}`, `{"blog.post":1,"shop.buy":-1}`, `{}`, }, own: "NULL", merged: map[string]int{"blog.post": 1, "blog.edit": 1, "shop.buy": 0, "news.read": 1}, asks: []ask{{"blog.post", true}, {"blog.edit", true}, {"shop.buy", false}, {"news.read", true}, {"news.*", true}}, }, { name: "numbers stored as text count at both levels", groups: []string{`{"blog.post":"1","blog.edit":"1","shop.buy":"-1"}`}, own: `{"blog.edit":"-1","shop.buy":"1","news.read":"0"}`, merged: map[string]int{"blog.post": 1, "blog.edit": -1, "shop.buy": 1, "news.read": 0}, asks: []ask{{"blog.post", true}, {"blog.edit", false}, {"shop.buy", true}, {"news.read", false}}, }, { name: "groups that store nothing leave the user's own permissions", groups: []string{`{}`, ``, `[]`}, own: `{"blog.post":1}`, merged: map[string]int{"blog.post": 1}, asks: []ask{{"blog.post", true}, {"blog.edit", false}}, }, { name: "a code present only at user level is held or denied by that value alone", groups: []string{`{"blog.post":1}`}, own: `{"only.allow":1,"only.deny":-1,"only.inherit":0}`, merged: map[string]int{"blog.post": 1, "only.allow": 1, "only.deny": -1, "only.inherit": 0}, asks: []ask{{"only.allow", true}, {"only.deny", false}, {"only.inherit", false}, {"only.*", true}, {"blog.post", true}}, }, { // 12.1-SECURITY-REVIEW.md, open item 5: the resolver reads // numbers only. A boolean is not a grant here. name: "a boolean value is not a grant", groups: []string{`{"blog.post":true,"blog.edit":false}`}, own: `{"shop.buy":true}`, merged: map[string]int{}, asks: []ask{{"blog.post", false}, {"shop.buy", false}}, }, } for i, c := range cases { t.Run(c.name, func(t *testing.T) { user := models.User{Email: fmt.Sprintf("merged-%d@example.test", i)} if err := db.Omit("Groups").Create(&user).Error; err != nil { t.Fatal(err) } if c.own != "NULL" { if err := db.Exec(`UPDATE users SET permissions = ? WHERE id = ?`, c.own, user.ID).Error; err != nil { t.Fatal(err) } } for j, stored := range c.groups { var groupID uint if err := db.Raw(`INSERT INTO user_groups (name, permissions, created_at, updated_at) VALUES (?, ?, NOW(), NOW()) RETURNING id`, fmt.Sprintf("Merged %d.%d", i, j), stored).Scan(&groupID).Error; err != nil { t.Fatal(err) } if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, user.ID, groupID).Error; err != nil { t.Fatal(err) } } merged, err := MergedPermissions(ctx, db, user.ID) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(merged, c.merged) { t.Fatalf("merged = %v, want %v", merged, c.merged) } for _, a := range c.asks { if got := HasPermission(merged, a.code); got != a.want { t.Errorf("HasPermission(%q) = %v, want %v", a.code, got, a.want) } got, err := UserHasPermission(ctx, db, user.ID, a.code) if err != nil || got != a.want { t.Errorf("UserHasPermission(%q) = %v %v, want %v", a.code, got, err, a.want) } } }) } // A deactivated user's permissions are still read. gone := models.User{Email: "merged-gone@example.test"} if err := db.Omit("Groups").Create(&gone).Error; err != nil { t.Fatal(err) } if err := db.Exec(`UPDATE users SET permissions = '{"blog.post":1}', deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil { t.Fatal(err) } if held, err := UserHasPermission(ctx, db, gone.ID, "blog.post"); err != nil || !held { t.Fatalf("deactivated user: %v %v", held, err) } // A user who does not exist holds nothing. if merged, err := MergedPermissions(ctx, db, 987654321); err != nil || len(merged) != 0 { t.Fatalf("unknown user: %v %v", merged, err) } // A damaged row is an error, never "no permissions". damaged := models.User{Email: "merged-damaged@example.test"} if err := db.Omit("Groups").Create(&damaged).Error; err != nil { t.Fatal(err) } if err := db.Exec(`UPDATE users SET permissions = '{"blog.post":' WHERE id = ?`, damaged.ID).Error; err != nil { t.Fatal(err) } if _, err := MergedPermissions(ctx, db, damaged.ID); err == nil { t.Fatal("damaged permissions were read without an error") } if _, err := UserHasPermission(ctx, db, damaged.ID, "blog.post"); err == nil { t.Fatal("UserHasPermission swallowed the error") } if err := db.Exec(`DELETE FROM users WHERE id = ?`, damaged.ID).Error; err != nil { t.Fatal(err) } if _, err := MergedPermissions(ctx, nil, gone.ID); err == nil { t.Fatal("MergedPermissions without a handle") } if _, err := FrontendPermissionOptions(ctx, nil); err == nil { t.Fatal("FrontendPermissionOptions without a handle") } // The option catalogue is read by tab, then code. if err := db.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab, comment) VALUES ('merged.b', 'B', 'Zeta', NULL), ('merged.c', 'C', 'Alpha', 'A comment'), ('merged.a', 'A', 'Zeta', NULL)`).Error; err != nil { t.Fatal(err) } options, err := FrontendPermissionOptions(ctx, db) if err != nil { t.Fatal(err) } var codes []string for _, option := range options { if len(option.Code) > 7 && option.Code[:7] == "merged." { codes = append(codes, option.Code) if option.Code == "merged.c" && (option.Comment == nil || *option.Comment != "A comment" || option.Tab != "Alpha" || option.Label != "C") { t.Fatalf("option = %+v", option) } } } if want := []string{"merged.c", "merged.a", "merged.b"}; !reflect.DeepEqual(codes, want) { t.Fatalf("option order = %v, want %v", codes, want) } } // TestPermissionSetScan pins the tolerant read and the strict write of a // stored permission column. func TestPermissionSetScan(t *testing.T) { scans := []struct { name string src any want models.PermissionSet wantErr bool }{ {"NULL", nil, models.PermissionSet{}, false}, {"empty string", "", models.PermissionSet{}, false}, {"blank string", " \n", models.PermissionSet{}, false}, {"the text []", "[]", models.PermissionSet{}, false}, {"the text null", "null", models.PermissionSet{}, false}, {"empty object", "{}", models.PermissionSet{}, false}, {"bytes", []byte(`{"a.b":1}`), models.PermissionSet{"a.b": 1}, false}, {"integers", `{"a.b":1,"c.d":-1,"e.f":0}`, models.PermissionSet{"a.b": 1, "c.d": -1, "e.f": 0}, false}, {"numeric strings", `{"a.b":"1","c.d":"-1"," e.f":" 1 "}`, models.PermissionSet{"a.b": 1, "c.d": -1, " e.f": 1}, false}, {"a non-numeric entry is skipped", `{"a.b":1,"c.d":"yes","e.f":true,"g.h":null,"i.j":1.5,"k.l":[1],"m.n":{"x":1}}`, models.PermissionSet{"a.b": 1}, false}, {"malformed JSON", `{"a.b":1`, nil, true}, {"a non-empty list", `["a.b"]`, nil, true}, {"a scalar", `1`, nil, true}, {"a string scalar", `"a.b"`, nil, true}, {"trailing data", `{"a.b":1} {"c.d":1}`, nil, true}, {"an unsupported driver type", 42, nil, true}, } for _, c := range scans { t.Run(c.name, func(t *testing.T) { set := models.PermissionSet{"stale": 1} err := set.Scan(c.src) if c.wantErr { if err == nil { t.Fatalf("Scan(%v) = %v, want an error", c.src, set) } return } if err != nil || set == nil || !reflect.DeepEqual(set, c.want) { t.Fatalf("Scan(%v) = %v %v, want %v", c.src, set, err, c.want) } }) } values := []struct { name string set models.PermissionSet want any }{ {"nil set", nil, nil}, {"empty set", models.PermissionSet{}, nil}, {"only zeros", models.PermissionSet{"a.b": 0}, nil}, {"a zero is left out", models.PermissionSet{"b.c": -1, "a.b": 1, "z.z": 0}, `{"a.b":1,"b.c":-1}`}, } for _, c := range values { t.Run("value of "+c.name, func(t *testing.T) { got, err := c.set.Value() if err != nil || !reflect.DeepEqual(got, c.want) { t.Fatalf("Value() = %#v %v, want %#v", got, err, c.want) } }) } // Through GORM: a stored set comes back as written, an empty one as NULL. db := throttleDB(t) user := models.User{Email: "permission-set@example.test", Permissions: models.PermissionSet{"a.b": 1, "c.d": -1, "e.f": 0}} if err := db.Omit("Groups").Create(&user).Error; err != nil { t.Fatal(err) } var stored *string if err := db.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil || stored == nil || *stored != `{"a.b":1,"c.d":-1}` { t.Fatalf("stored permissions = %v err = %v", stored, err) } var loaded models.User if err := db.Take(&loaded, user.ID).Error; err != nil || !reflect.DeepEqual(loaded.Permissions, models.PermissionSet{"a.b": 1, "c.d": -1}) { t.Fatalf("loaded permissions = %v err = %v", loaded.Permissions, err) } loaded.Permissions = models.PermissionSet{} if err := db.Omit("Groups").Save(&loaded).Error; err != nil { t.Fatal(err) } if err := db.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil { t.Fatal(err) } var isNull bool if err := db.Raw(`SELECT permissions IS NULL FROM users WHERE id = ?`, user.ID).Scan(&isNull).Error; err != nil || !isNull { t.Fatalf("an empty set was not stored as NULL: %v", err) } }