From c8f9dda350758a76dd97f15745b8afaa1ce58d13 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 12:35:00 -0700 Subject: [PATCH 1/6] fix(oauth): bind first-party refresh tokens to client --- oauth/api/login.go | 13 +++++---- oauth/api/refresh_claims.go | 48 +++++++++++++++++++++++++++++++ oauth/api/refresh_claims_test.go | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 oauth/api/refresh_claims.go create mode 100644 oauth/api/refresh_claims_test.go diff --git a/oauth/api/login.go b/oauth/api/login.go index 19c39ca6..138dc395 100644 --- a/oauth/api/login.go +++ b/oauth/api/login.go @@ -71,18 +71,19 @@ func RefreshSession(c *gin.Context) { return } - entityID, _ := claims["sub"].(string) - scope, _ := claims["scope"].(string) - if entityID == "" || !service.ScopesContain(scope, "refresh_token") { + refreshClaims, err := parseRefreshTokenClaims(claims, config.SentinelClientID) + if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "not a refresh token"}) return } - if tokenID, ok := claims["jti"].(string); ok { - sentinel.Delete("/api/core/token/"+tokenID, nil) + if err := sentinel.Delete("/api/core/token/"+refreshClaims.TokenID, nil); err != nil { + logger.SugarLogger.Errorf("Failed to revoke first-party refresh token %s: %v", refreshClaims.TokenID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to rotate refresh token"}) + return } - resp, err := mintFirstPartySession(c, entityID) + resp, err := mintFirstPartySession(c, refreshClaims.EntityID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/oauth/api/refresh_claims.go b/oauth/api/refresh_claims.go new file mode 100644 index 00000000..5e6caa6c --- /dev/null +++ b/oauth/api/refresh_claims.go @@ -0,0 +1,48 @@ +package api + +import ( + "errors" + + "github.com/gaucho-racing/sentinel/oauth/service" +) + +var errInvalidRefreshTokenClaims = errors.New("invalid refresh token claims") + +type refreshTokenClaims struct { + EntityID string + Scope string + TokenID string +} + +func parseRefreshTokenClaims(claims map[string]interface{}, expectedAudience string) (refreshTokenClaims, error) { + entityID, _ := claims["sub"].(string) + scope, _ := claims["scope"].(string) + tokenID, _ := claims["jti"].(string) + if entityID == "" || tokenID == "" || !service.ScopesContain(scope, "refresh_token") { + return refreshTokenClaims{}, errInvalidRefreshTokenClaims + } + if !audienceMatches(claims["aud"], expectedAudience) { + return refreshTokenClaims{}, errInvalidRefreshTokenClaims + } + return refreshTokenClaims{EntityID: entityID, Scope: scope, TokenID: tokenID}, nil +} + +func audienceMatches(raw interface{}, expected string) bool { + if expected == "" { + return false + } + switch audience := raw.(type) { + case string: + return audience == expected + case []interface{}: + if len(audience) != 1 { + return false + } + value, ok := audience[0].(string) + return ok && value == expected + case []string: + return len(audience) == 1 && audience[0] == expected + default: + return false + } +} diff --git a/oauth/api/refresh_claims_test.go b/oauth/api/refresh_claims_test.go new file mode 100644 index 00000000..8869617b --- /dev/null +++ b/oauth/api/refresh_claims_test.go @@ -0,0 +1,49 @@ +package api + +import "testing" + +func TestParseRefreshTokenClaimsRequiresExactAudience(t *testing.T) { + tests := []struct { + name string + audience interface{} + valid bool + }{ + {name: "string", audience: "sentinel", valid: true}, + {name: "single item array", audience: []interface{}{"sentinel"}, valid: true}, + {name: "wrong client", audience: []interface{}{"third-party"}}, + {name: "multiple audiences", audience: []interface{}{"sentinel", "third-party"}}, + {name: "missing audience"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + claims := map[string]interface{}{ + "sub": "ent_1", + "scope": "openid refresh_token", + "jti": "jwt_1", + "aud": test.audience, + } + parsed, err := parseRefreshTokenClaims(claims, "sentinel") + if test.valid && err != nil { + t.Fatalf("expected valid claims, got %v", err) + } + if !test.valid && err == nil { + t.Fatalf("expected invalid claims, got %#v", parsed) + } + }) + } +} + +func TestParseRefreshTokenClaimsRequiresRefreshScopeAndTokenID(t *testing.T) { + tests := []map[string]interface{}{ + {"sub": "ent_1", "scope": "openid", "jti": "jwt_1", "aud": "sentinel"}, + {"sub": "ent_1", "scope": "openid refresh_token", "aud": "sentinel"}, + {"scope": "openid refresh_token", "jti": "jwt_1", "aud": "sentinel"}, + } + + for _, claims := range tests { + if parsed, err := parseRefreshTokenClaims(claims, "sentinel"); err == nil { + t.Fatalf("expected invalid claims, got %#v", parsed) + } + } +} From 50be3c4b5a31a5633d19d1fdd918b6093ee3338a Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 13:08:45 -0700 Subject: [PATCH 2/6] fix(oauth): consume refresh tokens atomically --- core/api/jwt.go | 6 ++++ core/service/jwt.go | 3 ++ core/service/jwt_test.go | 73 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 core/service/jwt_test.go diff --git a/core/api/jwt.go b/core/api/jwt.go index 21d64c5c..48cb0ebe 100644 --- a/core/api/jwt.go +++ b/core/api/jwt.go @@ -1,11 +1,13 @@ package api import ( + "errors" "net/http" "github.com/gaucho-racing/sentinel/core/config" "github.com/gaucho-racing/sentinel/core/service" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func JWKS(c *gin.Context) { @@ -67,6 +69,10 @@ func RevokeToken(c *gin.Context) { id := c.Param("id") if err := service.RevokeToken(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/core/service/jwt.go b/core/service/jwt.go index 2bfbe5ac..b20aec24 100644 --- a/core/service/jwt.go +++ b/core/service/jwt.go @@ -215,5 +215,8 @@ func RevokeToken(id string) error { logger.SugarLogger.Errorf("Failed to revoke token: %v", result.Error) return result.Error } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } return nil } diff --git a/core/service/jwt_test.go b/core/service/jwt_test.go new file mode 100644 index 00000000..a23b94f9 --- /dev/null +++ b/core/service/jwt_test.go @@ -0,0 +1,73 @@ +package service + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" + "github.com/gaucho-racing/ulid-go" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestRevokeTokenConsumesTokenOnce(t *testing.T) { + dsn := os.Getenv("CORE_TEST_DATABASE_DSN") + if dsn == "" { + t.Skip("CORE_TEST_DATABASE_DSN is not configured") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Token{}); err != nil { + t.Fatal(err) + } + + originalDB := database.DB + database.DB = db + defer func() { database.DB = originalDB }() + + token := model.Token{ + ID: ulid.Make().Prefixed("jwt"), + EntityID: ulid.Make().Prefixed("ent"), + ClientID: "sentinel", + Scope: "sentinel:all refresh_token", + ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&token).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Where("id = ?", token.ID).Delete(&model.Token{}) }) + + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + results <- RevokeToken(token.ID) + }() + } + close(start) + + var revoked int + var alreadyConsumed int + for range 2 { + err := <-results + switch { + case err == nil: + revoked++ + case errors.Is(err, gorm.ErrRecordNotFound): + alreadyConsumed++ + default: + t.Fatalf("unexpected revoke error: %v", err) + } + } + + if revoked != 1 || alreadyConsumed != 1 { + t.Fatalf("expected one successful revoke and one consumed-token error, got %d and %d", revoked, alreadyConsumed) + } +} From 535a2a45ef8905429eb0d43340b3cee46153a68f Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 13:17:37 -0700 Subject: [PATCH 3/6] chore(oauth): remove refresh token tests --- core/service/jwt_test.go | 73 -------------------------------- oauth/api/refresh_claims_test.go | 49 --------------------- 2 files changed, 122 deletions(-) delete mode 100644 core/service/jwt_test.go delete mode 100644 oauth/api/refresh_claims_test.go diff --git a/core/service/jwt_test.go b/core/service/jwt_test.go deleted file mode 100644 index a23b94f9..00000000 --- a/core/service/jwt_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package service - -import ( - "errors" - "os" - "testing" - "time" - - "github.com/gaucho-racing/sentinel/core/database" - "github.com/gaucho-racing/sentinel/core/model" - "github.com/gaucho-racing/ulid-go" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func TestRevokeTokenConsumesTokenOnce(t *testing.T) { - dsn := os.Getenv("CORE_TEST_DATABASE_DSN") - if dsn == "" { - t.Skip("CORE_TEST_DATABASE_DSN is not configured") - } - - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Token{}); err != nil { - t.Fatal(err) - } - - originalDB := database.DB - database.DB = db - defer func() { database.DB = originalDB }() - - token := model.Token{ - ID: ulid.Make().Prefixed("jwt"), - EntityID: ulid.Make().Prefixed("ent"), - ClientID: "sentinel", - Scope: "sentinel:all refresh_token", - ExpiresAt: time.Now().Add(time.Hour), - } - if err := db.Create(&token).Error; err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Where("id = ?", token.ID).Delete(&model.Token{}) }) - - start := make(chan struct{}) - results := make(chan error, 2) - for range 2 { - go func() { - <-start - results <- RevokeToken(token.ID) - }() - } - close(start) - - var revoked int - var alreadyConsumed int - for range 2 { - err := <-results - switch { - case err == nil: - revoked++ - case errors.Is(err, gorm.ErrRecordNotFound): - alreadyConsumed++ - default: - t.Fatalf("unexpected revoke error: %v", err) - } - } - - if revoked != 1 || alreadyConsumed != 1 { - t.Fatalf("expected one successful revoke and one consumed-token error, got %d and %d", revoked, alreadyConsumed) - } -} diff --git a/oauth/api/refresh_claims_test.go b/oauth/api/refresh_claims_test.go deleted file mode 100644 index 8869617b..00000000 --- a/oauth/api/refresh_claims_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package api - -import "testing" - -func TestParseRefreshTokenClaimsRequiresExactAudience(t *testing.T) { - tests := []struct { - name string - audience interface{} - valid bool - }{ - {name: "string", audience: "sentinel", valid: true}, - {name: "single item array", audience: []interface{}{"sentinel"}, valid: true}, - {name: "wrong client", audience: []interface{}{"third-party"}}, - {name: "multiple audiences", audience: []interface{}{"sentinel", "third-party"}}, - {name: "missing audience"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - claims := map[string]interface{}{ - "sub": "ent_1", - "scope": "openid refresh_token", - "jti": "jwt_1", - "aud": test.audience, - } - parsed, err := parseRefreshTokenClaims(claims, "sentinel") - if test.valid && err != nil { - t.Fatalf("expected valid claims, got %v", err) - } - if !test.valid && err == nil { - t.Fatalf("expected invalid claims, got %#v", parsed) - } - }) - } -} - -func TestParseRefreshTokenClaimsRequiresRefreshScopeAndTokenID(t *testing.T) { - tests := []map[string]interface{}{ - {"sub": "ent_1", "scope": "openid", "jti": "jwt_1", "aud": "sentinel"}, - {"sub": "ent_1", "scope": "openid refresh_token", "aud": "sentinel"}, - {"scope": "openid refresh_token", "jti": "jwt_1", "aud": "sentinel"}, - } - - for _, claims := range tests { - if parsed, err := parseRefreshTokenClaims(claims, "sentinel"); err == nil { - t.Fatalf("expected invalid claims, got %#v", parsed) - } - } -} From 9f0c21589daa96f78a0301007da2722d11846d79 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 12:36:16 -0700 Subject: [PATCH 4/6] fix(oauth): bind refresh grants to authenticated client --- oauth/api/token.go | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/oauth/api/token.go b/oauth/api/token.go index 3bdf6d91..4abe03d5 100644 --- a/oauth/api/token.go +++ b/oauth/api/token.go @@ -191,32 +191,31 @@ func handleRefreshTokenExchange(c *gin.Context) { return } - entityID, _ := claims["sub"].(string) - scope, _ := claims["scope"].(string) - - if !service.ScopesContain(scope, "refresh_token") { + refreshClaims, err := parseRefreshTokenClaims(claims, clientID) + if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "provided token is not a refresh token"}) return } - // Revoke the old refresh token - if tokenID, ok := claims["jti"].(string); ok { - sentinel.Delete("/api/core/token/"+tokenID, nil) + if err := sentinel.Delete("/api/core/token/"+refreshClaims.TokenID, nil); err != nil { + logger.SugarLogger.Errorf("Failed to revoke OAuth refresh token %s: %v", refreshClaims.TokenID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to rotate refresh token"}) + return } // Re-check the gate on refresh — group membership may have changed // since the original grant. If the user no longer qualifies, the // refresh fails and they have to re-authenticate (which will hit the // gate again at the authorize step). - if err := service.CheckAccessGate(entityID, clientID); err != nil { + if err := service.CheckAccessGate(refreshClaims.EntityID, clientID); err != nil { writeGateError(c, err) return } // Strip refresh_token from scope for the access token - accessScope := service.RemoveScope(scope, "refresh_token") + accessScope := service.RemoveScope(refreshClaims.Scope, "refresh_token") - newClaims, err := service.BuildTokenClaims(entityID, clientID, accessScope) + newClaims, err := service.BuildTokenClaims(refreshClaims.EntityID, clientID, accessScope) if err != nil { logger.SugarLogger.Errorf("Failed to build token claims: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"}) @@ -224,14 +223,14 @@ func handleRefreshTokenExchange(c *gin.Context) { } // Generate new access token - accessToken, accessTokenID, err := generateToken(entityID, clientID, accessScope, config.AccessTokenTTL, newClaims) + accessToken, accessTokenID, err := generateToken(refreshClaims.EntityID, clientID, accessScope, config.AccessTokenTTL, newClaims) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"}) return } // Generate new refresh token (keep refresh_token in scope) - newRefreshToken, newRefreshTokenID, err := generateToken(entityID, clientID, scope, config.RefreshTokenTTL, newClaims) + newRefreshToken, newRefreshTokenID, err := generateToken(refreshClaims.EntityID, clientID, refreshClaims.Scope, config.RefreshTokenTTL, newClaims) if err != nil { logger.SugarLogger.Errorf("Failed to generate refresh token: %v", err) newRefreshToken = "" @@ -239,7 +238,7 @@ func handleRefreshTokenExchange(c *gin.Context) { } sentinel.Post("/api/core/entity/logins", map[string]string{ - "entity_id": entityID, + "entity_id": refreshClaims.EntityID, "client_id": clientID, "scope": accessScope, "access_token_id": accessTokenID, @@ -253,13 +252,13 @@ func handleRefreshTokenExchange(c *gin.Context) { // carried forward. var idToken string if service.ScopesContain(accessScope, "openid") { - idClaims, idErr := service.BuildIDTokenClaims(entityID, clientID, accessScope, "", accessToken, time.Now().Unix()) + idClaims, idErr := service.BuildIDTokenClaims(refreshClaims.EntityID, clientID, accessScope, "", accessToken, time.Now().Unix()) if idErr != nil { logger.SugarLogger.Errorf("Failed to build id token claims: %v", idErr) c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"}) return } - idToken, _, err = generateToken(entityID, clientID, accessScope, config.AccessTokenTTL, idClaims) + idToken, _, err = generateToken(refreshClaims.EntityID, clientID, accessScope, config.AccessTokenTTL, idClaims) if err != nil { logger.SugarLogger.Errorf("Failed to generate id token: %v", err) idToken = "" From 3b12a8dc14e563d05bb9c736fcfd8c34585852a7 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 12:45:33 -0700 Subject: [PATCH 5/6] fix(core): enforce join request integrity --- core/api/api.go | 10 +- core/api/group.go | 177 +++++++++++------- core/api/internal_access_test.go | 25 +++ core/service/group.go | 86 +++++++-- core/service/group_join_request_test.go | 83 ++++++++ web/src/pages/groups/GroupDetailsPage.tsx | 2 - .../pages/groups/GroupRequestDetailsPage.tsx | 2 - web/src/pages/groups/ReviewRequestDialog.tsx | 5 +- 8 files changed, 300 insertions(+), 90 deletions(-) create mode 100644 core/api/internal_access_test.go create mode 100644 core/service/group_join_request_test.go diff --git a/core/api/api.go b/core/api/api.go index 6e71f1e0..87187924 100644 --- a/core/api/api.go +++ b/core/api/api.go @@ -293,6 +293,14 @@ func RequestTokenHasUserID(c *gin.Context, userID string) bool { return GetRequestTokenUserID(c) == userID } +func RequestTokenHasInternalAccess(c *gin.Context) bool { + claims := GetRequestTokenClaims(c) + if claims == nil || claims["type"] != "service_account" { + return false + } + return RequestTokenHasScope(c, "sentinel:all") +} + // RequestUserIsAdmin reports whether the bearer's subject entity is a // member of the Admins group. Used to grant admin-only write access // without requiring per-resource ownership. Returns false for unauth'd @@ -324,7 +332,7 @@ func RequestUserIsGroupOwner(c *gin.Context, groupID string) bool { // the caller continues. func requireGroupOwnerOrAdmin(c *gin.Context, groupID string) bool { if Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestUserIsGroupOwner(c, groupID), RequestUserIsAdmin(c), ) { diff --git a/core/api/group.go b/core/api/group.go index 136d4599..b7f7abaf 100644 --- a/core/api/group.go +++ b/core/api/group.go @@ -141,7 +141,7 @@ func CreateOrUpdateGroup(c *gin.Context) { // on any group, including the Admins group. if existing.ID == "" { Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasScope(c, "groups:write"), )) } else if !requireGroupOwnerOrAdmin(c, existing.ID) { @@ -262,7 +262,7 @@ type addGroupMemberRequest struct { } func requestAddedBy(c *gin.Context, claimed string) string { - if RequestTokenHasScope(c, "sentinel:all") && claimed != "" { + if RequestTokenHasInternalAccess(c) && claimed != "" { return claimed } return GetRequestTokenEntityID(c) @@ -303,13 +303,13 @@ func AddGroupMember(c *gin.Context) { if source == "" { source = string(model.GroupMemberSourceDirect) } - if source != string(model.GroupMemberSourceDirect) && !RequestTokenHasScope(c, "sentinel:all") { + if source != string(model.GroupMemberSourceDirect) && !RequestTokenHasInternalAccess(c) { c.JSON(http.StatusForbidden, gin.H{"error": "only internal services can add synced group members"}) return } if source == string(model.GroupMemberSourceDirect) && !containsSource(group.AllowedSources, model.GroupMemberSourceDirect) && - !RequestTokenHasScope(c, "sentinel:all") { + !RequestTokenHasInternalAccess(c) { c.JSON(http.StatusBadRequest, gin.H{"error": "direct memberships are not enabled for this group"}) return } @@ -460,8 +460,9 @@ func GetGroupJoinRequests(c *gin.Context) { } func GetGroupJoinRequest(c *gin.Context) { + id := c.Param("id") requestID := c.Param("requestID") - request, err := service.GetJoinRequestByID(requestID) + request, err := service.GetJoinRequestForGroup(id, requestID) if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) @@ -473,7 +474,7 @@ func GetGroupJoinRequest(c *gin.Context) { // Applicants can read their own request; otherwise the group's // owner roster, admins, and internal services can see it. Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasEntityID(c, request.EntityID), RequestUserIsGroupOwner(c, request.GroupID), RequestUserIsAdmin(c), @@ -499,7 +500,7 @@ func CreateGroupJoinRequest(c *gin.Context) { // that is admin or internal; group owners can't backdoor people in // via this endpoint (they'd use AddGroupMember directly). Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasEntityID(c, req.EntityID), RequestUserIsAdmin(c), )) @@ -527,7 +528,6 @@ func CreateGroupJoinRequest(c *gin.Context) { } type reviewJoinRequestRequest struct { - ReviewedBy string `json:"reviewed_by" binding:"required"` // Optional approval-time overrides. When provided, they replace the // expiration that the requester originally chose — used by reviewers // who want to grant a shorter/longer membership than what was asked @@ -539,25 +539,24 @@ type reviewJoinRequestRequest struct { func ApproveGroupJoinRequest(c *gin.Context) { id := c.Param("id") - if !requireGroupOwnerOrAdmin(c, id) { - return - } requestID := c.Param("requestID") - var req reviewJoinRequestRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - request, err := service.GetJoinRequestByID(requestID) + request, err := service.GetJoinRequestForGroup(id, requestID) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - + if !requireGroupOwnerOrAdmin(c, id) { + return + } + var req reviewJoinRequestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } hasExpiration := request.HasExpiration expiresAt := request.ExpiresAt if req.HasExpiration != nil { @@ -571,25 +570,23 @@ func ApproveGroupJoinRequest(c *gin.Context) { return } - request.Status = string(model.GroupJoinRequestStatusApproved) - request.ReviewedBy = req.ReviewedBy - request.ReviewedAt = time.Now() - request.HasExpiration = hasExpiration - request.ExpiresAt = expiresAt - request, err = service.UpdateJoinRequest(request) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - _, err = service.CreateGroupMember(model.GroupMember{ - GroupID: request.GroupID, - EntityID: request.EntityID, - Source: string(model.GroupMemberSourceDirect), - AddedBy: req.ReviewedBy, - HasExpiration: hasExpiration, - ExpiresAt: expiresAt, - }) + request, err = service.ReviewJoinRequest( + id, + requestID, + GetRequestTokenEntityID(c), + model.GroupJoinRequestStatusApproved, + hasExpiration, + expiresAt, + ) if err != nil { + if errors.Is(err, service.ErrJoinRequestNotPending) || errors.Is(err, service.ErrGroupMemberExists) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -597,37 +594,50 @@ func ApproveGroupJoinRequest(c *gin.Context) { "group_id": request.GroupID, "entity_id": request.EntityID, }) + service.ReconcileConditionalForEntity(request.EntityID) c.JSON(http.StatusOK, request) } func RejectGroupJoinRequest(c *gin.Context) { id := c.Param("id") + requestID := c.Param("requestID") + request, err := service.GetJoinRequestForGroup(id, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } if !requireGroupOwnerOrAdmin(c, id) { return } - requestID := c.Param("requestID") var req reviewJoinRequestRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - request, err := service.GetJoinRequestByID(requestID) + request, err = service.ReviewJoinRequest( + id, + requestID, + GetRequestTokenEntityID(c), + model.GroupJoinRequestStatusRejected, + request.HasExpiration, + request.ExpiresAt, + ) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, service.ErrJoinRequestNotPending) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - request.Status = string(model.GroupJoinRequestStatusRejected) - request.ReviewedBy = req.ReviewedBy - request.ReviewedAt = time.Now() - request, err = service.UpdateJoinRequest(request) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } recordAudit(c, model.AuditActionJoinRequestRejected, "join_request", requestID, model.JSONMap{ "group_id": request.GroupID, "entity_id": request.EntityID, @@ -637,11 +647,30 @@ func RejectGroupJoinRequest(c *gin.Context) { func DeleteGroupJoinRequest(c *gin.Context) { id := c.Param("id") - if !requireGroupOwnerOrAdmin(c, id) { + requestID := c.Param("requestID") + request, err := service.GetJoinRequestForGroup(id, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - requestID := c.Param("requestID") - if err := service.DeleteJoinRequest(requestID); err != nil { + if !Any( + RequestTokenHasInternalAccess(c), + RequestTokenHasEntityID(c, request.EntityID) && request.Status == string(model.GroupJoinRequestStatusPending), + RequestUserIsGroupOwner(c, id), + RequestUserIsAdmin(c), + ) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "you are not authorized to delete this join request"}) + return + } + if err := service.DeleteJoinRequestForGroup(id, requestID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -651,8 +680,7 @@ func DeleteGroupJoinRequest(c *gin.Context) { // Join Request Comments type createJoinRequestCommentRequest struct { - EntityID string `json:"entity_id" binding:"required"` - Comment string `json:"comment" binding:"required"` + Comment string `json:"comment" binding:"required"` } func CreateJoinRequestComment(c *gin.Context) { @@ -663,20 +691,25 @@ func CreateJoinRequestComment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Comments are scoped to the join-request thread: the requester - // (commenting on their own request) and the group's owners / - // admins (reviewing the request) are the legitimate posters. - // Bearer must match the comment's claimed entity_id; the owner/ - // admin path bypasses the self check. + request, err := service.GetJoinRequestForGroup(id, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + actorID := GetRequestTokenEntityID(c) Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), - RequestTokenHasEntityID(c, req.EntityID), + RequestTokenHasInternalAccess(c), + actorID == request.EntityID, RequestUserIsGroupOwner(c, id), RequestUserIsAdmin(c), )) comment, err := service.CreateJoinRequestComment(model.GroupJoinRequestComment{ RequestID: requestID, - EntityID: req.EntityID, + EntityID: actorID, Comment: req.Comment, }) if err != nil { @@ -688,11 +721,17 @@ func CreateJoinRequestComment(c *gin.Context) { func DeleteJoinRequestComment(c *gin.Context) { id := c.Param("id") + requestID := c.Param("requestID") commentID := c.Param("commentID") - // Look up the comment first so we can authorize against its - // claimed author (the entity who posted it can delete their own - // comment; otherwise owner/admin/internal). - comment, err := service.GetJoinRequestComment(commentID) + if _, err := service.GetJoinRequestForGroup(id, requestID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "join request not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + comment, err := service.GetJoinRequestCommentForRequest(requestID, commentID) if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) @@ -702,12 +741,16 @@ func DeleteJoinRequestComment(c *gin.Context) { return } Require(c, Any( - RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasInternalAccess(c), RequestTokenHasEntityID(c, comment.EntityID), RequestUserIsGroupOwner(c, id), RequestUserIsAdmin(c), )) - if err := service.DeleteJoinRequestComment(commentID); err != nil { + if err := service.DeleteJoinRequestCommentForRequest(requestID, commentID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/core/api/internal_access_test.go b/core/api/internal_access_test.go new file mode 100644 index 00000000..23938b2d --- /dev/null +++ b/core/api/internal_access_test.go @@ -0,0 +1,25 @@ +package api + +import ( + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRequestTokenHasInternalAccessRequiresServiceAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + + userContext, _ := gin.CreateTestContext(nil) + userContext.Set("Auth-Scope", "sentinel:all") + userContext.Set("Auth-Claims", map[string]interface{}{"user_id": "usr_1"}) + if RequestTokenHasInternalAccess(userContext) { + t.Fatal("user session must not receive internal access") + } + + serviceContext, _ := gin.CreateTestContext(nil) + serviceContext.Set("Auth-Scope", "sentinel:all") + serviceContext.Set("Auth-Claims", map[string]interface{}{"type": "service_account"}) + if !RequestTokenHasInternalAccess(serviceContext) { + t.Fatal("internal service account should receive internal access") + } +} diff --git a/core/service/group.go b/core/service/group.go index 2567000e..9d9a162b 100644 --- a/core/service/group.go +++ b/core/service/group.go @@ -1,16 +1,26 @@ package service import ( + "errors" + "time" + "github.com/gaucho-racing/sentinel/core/database" "github.com/gaucho-racing/sentinel/core/model" "github.com/gaucho-racing/sentinel/core/pkg/logger" "github.com/gaucho-racing/ulid-go" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) // AdminsGroupID is the fixed ID of the global Admins group. Members get // owner-equivalent permissions on every group and other admin-gated surfaces. const AdminsGroupID = "grp_01kqs3w6h82xkdnft94vpj7qrm" +var ( + ErrJoinRequestNotPending = errors.New("join request is not pending") + ErrGroupMemberExists = errors.New("entity is already a member of this group") +) + // IsAdmin reports whether the given entity is a member of the Admins group. // Returns false if the lookup fails so callers can treat it as a deny-by-default. func IsAdmin(entityID string) bool { @@ -203,9 +213,9 @@ func GetJoinRequestsByEntity(entityID string) ([]model.GroupJoinRequest, error) return requests, nil } -func GetJoinRequestByID(id string) (model.GroupJoinRequest, error) { +func GetJoinRequestForGroup(groupID string, id string) (model.GroupJoinRequest, error) { var request model.GroupJoinRequest - if err := database.DB.Where("id = ?", id).First(&request).Error; err != nil { + if err := database.DB.Where("id = ? AND group_id = ?", id, groupID).First(&request).Error; err != nil { return model.GroupJoinRequest{}, err } PopulateJoinRequest(&request) @@ -223,19 +233,63 @@ func CreateJoinRequest(request model.GroupJoinRequest) (model.GroupJoinRequest, return request, nil } -func UpdateJoinRequest(request model.GroupJoinRequest) (model.GroupJoinRequest, error) { - if err := database.DB.Save(&request).Error; err != nil { +func ReviewJoinRequest(groupID string, id string, reviewerID string, status model.GroupJoinRequestStatus, hasExpiration bool, expiresAt time.Time) (model.GroupJoinRequest, error) { + var request model.GroupJoinRequest + err := database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND group_id = ?", id, groupID). + First(&request).Error; err != nil { + return err + } + if request.Status != string(model.GroupJoinRequestStatusPending) { + return ErrJoinRequestNotPending + } + + if status == model.GroupJoinRequestStatusApproved { + member := model.GroupMember{ + GroupID: request.GroupID, + EntityID: request.EntityID, + Source: string(model.GroupMemberSourceDirect), + AddedBy: reviewerID, + HasExpiration: hasExpiration, + ExpiresAt: expiresAt, + } + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&member) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrGroupMemberExists + } + } + + request.Status = string(status) + request.ReviewedBy = reviewerID + request.ReviewedAt = time.Now() + request.HasExpiration = hasExpiration + request.ExpiresAt = expiresAt + return tx.Save(&request).Error + }) + if err != nil { return model.GroupJoinRequest{}, err } PopulateJoinRequest(&request) return request, nil } -func DeleteJoinRequest(id string) error { - if err := database.DB.Where("id = ?", id).Delete(&model.GroupJoinRequest{}).Error; err != nil { - return err - } - return nil +func DeleteJoinRequestForGroup(groupID string, id string) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + var request model.GroupJoinRequest + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND group_id = ?", id, groupID). + First(&request).Error; err != nil { + return err + } + if err := tx.Where("request_id = ?", request.ID).Delete(&model.GroupJoinRequestComment{}).Error; err != nil { + return err + } + return tx.Delete(&request).Error + }) } func PopulateJoinRequest(request *model.GroupJoinRequest) { @@ -257,9 +311,9 @@ func GetCommentsForJoinRequest(requestID string) ([]model.GroupJoinRequestCommen // GetJoinRequestComment returns a single comment by ID. Used by the // delete handler to authorize the requester against the comment's // claimed author before letting them delete it. -func GetJoinRequestComment(id string) (model.GroupJoinRequestComment, error) { +func GetJoinRequestCommentForRequest(requestID string, id string) (model.GroupJoinRequestComment, error) { var comment model.GroupJoinRequestComment - if err := database.DB.Where("id = ?", id).First(&comment).Error; err != nil { + if err := database.DB.Where("id = ? AND request_id = ?", id, requestID).First(&comment).Error; err != nil { return model.GroupJoinRequestComment{}, err } return comment, nil @@ -275,9 +329,13 @@ func CreateJoinRequestComment(comment model.GroupJoinRequestComment) (model.Grou return comment, nil } -func DeleteJoinRequestComment(id string) error { - if err := database.DB.Where("id = ?", id).Delete(&model.GroupJoinRequestComment{}).Error; err != nil { - return err +func DeleteJoinRequestCommentForRequest(requestID string, id string) error { + result := database.DB.Where("id = ? AND request_id = ?", id, requestID).Delete(&model.GroupJoinRequestComment{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound } return nil } diff --git a/core/service/group_join_request_test.go b/core/service/group_join_request_test.go new file mode 100644 index 00000000..638532d2 --- /dev/null +++ b/core/service/group_join_request_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/gaucho-racing/sentinel/core/database" + "github.com/gaucho-racing/sentinel/core/model" + "github.com/gaucho-racing/ulid-go" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestReviewJoinRequestScopesParentAndCommitsMembershipAtomically(t *testing.T) { + dsn := os.Getenv("CORE_TEST_DATABASE_DSN") + if dsn == "" { + t.Skip("CORE_TEST_DATABASE_DSN is not configured") + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.GroupJoinRequest{}, &model.GroupJoinRequestComment{}, &model.GroupMember{}); err != nil { + t.Fatal(err) + } + + originalDB := database.DB + database.DB = db + defer func() { database.DB = originalDB }() + + requestID := ulid.Make().Prefixed("gjr") + groupID := ulid.Make().Prefixed("grp") + wrongGroupID := ulid.Make().Prefixed("grp") + entityID := ulid.Make().Prefixed("ent") + request := model.GroupJoinRequest{ + ID: requestID, + GroupID: groupID, + EntityID: entityID, + Status: string(model.GroupJoinRequestStatusPending), + } + if err := db.Create(&request).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + db.Where("group_id = ? AND entity_id = ?", groupID, entityID).Delete(&model.GroupMember{}) + db.Where("id = ?", requestID).Delete(&model.GroupJoinRequest{}) + }) + + if _, err := ReviewJoinRequest(wrongGroupID, requestID, "reviewer", model.GroupJoinRequestStatusApproved, false, time.Time{}); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected parent mismatch to return not found, got %v", err) + } + + var unchanged model.GroupJoinRequest + if err := db.Where("id = ?", requestID).First(&unchanged).Error; err != nil { + t.Fatal(err) + } + if unchanged.Status != string(model.GroupJoinRequestStatusPending) { + t.Fatalf("request changed after mismatched parent: %s", unchanged.Status) + } + + reviewed, err := ReviewJoinRequest(groupID, requestID, "reviewer", model.GroupJoinRequestStatusApproved, false, time.Time{}) + if err != nil { + t.Fatal(err) + } + if reviewed.Status != string(model.GroupJoinRequestStatusApproved) || reviewed.ReviewedBy != "reviewer" { + t.Fatalf("unexpected reviewed request: %#v", reviewed) + } + + var member model.GroupMember + if err := db.Where("group_id = ? AND entity_id = ?", groupID, entityID).First(&member).Error; err != nil { + t.Fatal(err) + } + if member.AddedBy != "reviewer" { + t.Fatalf("membership actor = %q", member.AddedBy) + } + + if _, err := ReviewJoinRequest(groupID, requestID, "other", model.GroupJoinRequestStatusApproved, false, time.Time{}); !errors.Is(err, ErrJoinRequestNotPending) { + t.Fatalf("expected repeated review to be rejected, got %v", err) + } +} diff --git a/web/src/pages/groups/GroupDetailsPage.tsx b/web/src/pages/groups/GroupDetailsPage.tsx index 7a104071..f02c7c65 100644 --- a/web/src/pages/groups/GroupDetailsPage.tsx +++ b/web/src/pages/groups/GroupDetailsPage.tsx @@ -492,7 +492,6 @@ export default function GroupDetailsPage() { const reason = joinReason.trim() if (reason) { await api.post(`/groups/${id}/requests/${res.data.id}/comments`, { - entity_id: myEntityID, comment: reason, }) } @@ -1199,7 +1198,6 @@ export default function GroupDetailsPage() { groupID={id ?? ""} request={reviewTarget?.request ?? null} action={reviewTarget?.action ?? "approve"} - reviewerEntityID={myEntityID} /> {addPersonOpen && ( diff --git a/web/src/pages/groups/GroupRequestDetailsPage.tsx b/web/src/pages/groups/GroupRequestDetailsPage.tsx index 9489d7a1..079f4cd2 100644 --- a/web/src/pages/groups/GroupRequestDetailsPage.tsx +++ b/web/src/pages/groups/GroupRequestDetailsPage.tsx @@ -179,7 +179,6 @@ export default function GroupRequestDetailsPage() { setPosting(true) try { await api.post(`/groups/${id}/requests/${requestID}/comments`, { - entity_id: myEntityID, comment: body, }) qc.invalidateQueries({ queryKey: ["group", id, "requests", requestID] }) @@ -421,7 +420,6 @@ export default function GroupRequestDetailsPage() { groupID={id ?? ""} request={request} action={reviewAction ?? "approve"} - reviewerEntityID={myEntityID} /> ) diff --git a/web/src/pages/groups/ReviewRequestDialog.tsx b/web/src/pages/groups/ReviewRequestDialog.tsx index 87e60b92..e9de0b39 100644 --- a/web/src/pages/groups/ReviewRequestDialog.tsx +++ b/web/src/pages/groups/ReviewRequestDialog.tsx @@ -41,14 +41,12 @@ export function ReviewRequestDialog({ groupID, request, action, - reviewerEntityID, }: { open: boolean onOpenChange: (open: boolean) => void groupID: string request: GroupJoinRequest | null action: Action - reviewerEntityID: string }) { const qc = useQueryClient() const [comment, setComment] = useState("") @@ -101,12 +99,11 @@ export function ReviewRequestDialog({ const trimmed = comment.trim() if (trimmed) { await api.post(`/groups/${groupID}/requests/${request.id}/comments`, { - entity_id: reviewerEntityID, comment: trimmed, }) } - const payload: Record = { reviewed_by: reviewerEntityID } + const payload: Record = {} if (overrideExpiresAt) { payload.has_expiration = true payload.expires_at = overrideExpiresAt.toISOString() From 46e77b200d5744a78ececb04c9676df5546dfc29 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 13:18:27 -0700 Subject: [PATCH 6/6] chore(core): remove integrity tests --- core/api/internal_access_test.go | 25 -------- core/service/group_join_request_test.go | 83 ------------------------- 2 files changed, 108 deletions(-) delete mode 100644 core/api/internal_access_test.go delete mode 100644 core/service/group_join_request_test.go diff --git a/core/api/internal_access_test.go b/core/api/internal_access_test.go deleted file mode 100644 index 23938b2d..00000000 --- a/core/api/internal_access_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package api - -import ( - "testing" - - "github.com/gin-gonic/gin" -) - -func TestRequestTokenHasInternalAccessRequiresServiceAccount(t *testing.T) { - gin.SetMode(gin.TestMode) - - userContext, _ := gin.CreateTestContext(nil) - userContext.Set("Auth-Scope", "sentinel:all") - userContext.Set("Auth-Claims", map[string]interface{}{"user_id": "usr_1"}) - if RequestTokenHasInternalAccess(userContext) { - t.Fatal("user session must not receive internal access") - } - - serviceContext, _ := gin.CreateTestContext(nil) - serviceContext.Set("Auth-Scope", "sentinel:all") - serviceContext.Set("Auth-Claims", map[string]interface{}{"type": "service_account"}) - if !RequestTokenHasInternalAccess(serviceContext) { - t.Fatal("internal service account should receive internal access") - } -} diff --git a/core/service/group_join_request_test.go b/core/service/group_join_request_test.go deleted file mode 100644 index 638532d2..00000000 --- a/core/service/group_join_request_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "errors" - "os" - "testing" - "time" - - "github.com/gaucho-racing/sentinel/core/database" - "github.com/gaucho-racing/sentinel/core/model" - "github.com/gaucho-racing/ulid-go" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func TestReviewJoinRequestScopesParentAndCommitsMembershipAtomically(t *testing.T) { - dsn := os.Getenv("CORE_TEST_DATABASE_DSN") - if dsn == "" { - t.Skip("CORE_TEST_DATABASE_DSN is not configured") - } - - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.GroupJoinRequest{}, &model.GroupJoinRequestComment{}, &model.GroupMember{}); err != nil { - t.Fatal(err) - } - - originalDB := database.DB - database.DB = db - defer func() { database.DB = originalDB }() - - requestID := ulid.Make().Prefixed("gjr") - groupID := ulid.Make().Prefixed("grp") - wrongGroupID := ulid.Make().Prefixed("grp") - entityID := ulid.Make().Prefixed("ent") - request := model.GroupJoinRequest{ - ID: requestID, - GroupID: groupID, - EntityID: entityID, - Status: string(model.GroupJoinRequestStatusPending), - } - if err := db.Create(&request).Error; err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - db.Where("group_id = ? AND entity_id = ?", groupID, entityID).Delete(&model.GroupMember{}) - db.Where("id = ?", requestID).Delete(&model.GroupJoinRequest{}) - }) - - if _, err := ReviewJoinRequest(wrongGroupID, requestID, "reviewer", model.GroupJoinRequestStatusApproved, false, time.Time{}); !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("expected parent mismatch to return not found, got %v", err) - } - - var unchanged model.GroupJoinRequest - if err := db.Where("id = ?", requestID).First(&unchanged).Error; err != nil { - t.Fatal(err) - } - if unchanged.Status != string(model.GroupJoinRequestStatusPending) { - t.Fatalf("request changed after mismatched parent: %s", unchanged.Status) - } - - reviewed, err := ReviewJoinRequest(groupID, requestID, "reviewer", model.GroupJoinRequestStatusApproved, false, time.Time{}) - if err != nil { - t.Fatal(err) - } - if reviewed.Status != string(model.GroupJoinRequestStatusApproved) || reviewed.ReviewedBy != "reviewer" { - t.Fatalf("unexpected reviewed request: %#v", reviewed) - } - - var member model.GroupMember - if err := db.Where("group_id = ? AND entity_id = ?", groupID, entityID).First(&member).Error; err != nil { - t.Fatal(err) - } - if member.AddedBy != "reviewer" { - t.Fatalf("membership actor = %q", member.AddedBy) - } - - if _, err := ReviewJoinRequest(groupID, requestID, "other", model.GroupJoinRequestStatusApproved, false, time.Time{}); !errors.Is(err, ErrJoinRequestNotPending) { - t.Fatalf("expected repeated review to be rejected, got %v", err) - } -}