Skip to content

Commit 96ed405

Browse files
committed
fix: add authorization checks and improve test coverage
API Spec Compliance Fixes: - Add authorization checks for article update/delete (403 if not author) - Add authorization checks for comment delete (403 if not author) - Change comment creation to return 201 Created instead of 200 OK - Maintain DELETE idempotency (200 OK even if resource doesn't exist) - Add FindOneComment helper function for comment authorization Test Coverage Improvements: - articles: 92.1% → 93.4% (+1.3%) - Added tests for unauthorized update/delete attempts - Added test for unauthorized comment deletion - common: 85.7% → 87.1% (+1.4%) - Added tests for database directory creation paths - Added test for current directory database creation Security Enhancements: - Prevent non-authors from modifying/deleting articles - Prevent non-authors from deleting comments - Return proper 403 Forbidden status for authorization failures All tests passing (articles, common, users)
1 parent 3a16e06 commit 96ed405

4 files changed

Lines changed: 211 additions & 15 deletions

File tree

articles/models.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,3 +362,10 @@ func DeleteCommentModel(condition interface{}) error {
362362
err := db.Where(condition).Delete(&CommentModel{}).Error
363363
return err
364364
}
365+
366+
func FindOneComment(condition *CommentModel) (*CommentModel, error) {
367+
db := common.GetDB()
368+
var commentModel CommentModel
369+
err := db.Where(condition).First(&commentModel).Error
370+
return &commentModel, err
371+
}

articles/routers.go

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"github.com/gin-gonic/gin"
66
"github.com/gothinkster/golang-gin-realworld-example-app/common"
77
"github.com/gothinkster/golang-gin-realworld-example-app/users"
8+
"gorm.io/gorm"
89
"net/http"
910
"strconv"
1011
)
@@ -102,6 +103,14 @@ func ArticleUpdate(c *gin.Context) {
102103
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
103104
return
104105
}
106+
// Check if current user is the author
107+
myUserModel := c.MustGet("my_user_model").(users.UserModel)
108+
articleUserModel := GetArticleUserModel(myUserModel)
109+
if articleModel.AuthorID != articleUserModel.ID {
110+
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
111+
return
112+
}
113+
105114
articleModelValidator := NewArticleModelValidatorFillWith(articleModel)
106115
if err := articleModelValidator.Bind(c); err != nil {
107116
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
@@ -119,11 +128,18 @@ func ArticleUpdate(c *gin.Context) {
119128

120129
func ArticleDelete(c *gin.Context) {
121130
slug := c.Param("slug")
122-
err := DeleteArticleModel(&ArticleModel{Slug: slug})
123-
if err != nil {
124-
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
125-
return
126-
}
131+
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
132+
if err == nil {
133+
// Article exists, check authorization
134+
myUserModel := c.MustGet("my_user_model").(users.UserModel)
135+
articleUserModel := GetArticleUserModel(myUserModel)
136+
if articleModel.AuthorID != articleUserModel.ID {
137+
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
138+
return
139+
}
140+
}
141+
// Delete regardless of existence (idempotent)
142+
DeleteArticleModel(&ArticleModel{Slug: slug})
127143
c.Status(http.StatusOK)
128144
}
129145

@@ -178,21 +194,28 @@ func ArticleCommentCreate(c *gin.Context) {
178194
return
179195
}
180196
serializer := CommentSerializer{c, commentModelValidator.commentModel}
181-
c.JSON(http.StatusOK, gin.H{"comment": serializer.Response()})
197+
c.JSON(http.StatusCreated, gin.H{"comment": serializer.Response()})
182198
}
183199

184200
func ArticleCommentDelete(c *gin.Context) {
185201
id64, err := strconv.ParseUint(c.Param("id"), 10, 32)
186-
id := uint(id64)
187-
if err != nil {
188-
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid id")))
189-
return
190-
}
191-
err = DeleteCommentModel([]uint{id})
192202
if err != nil {
193203
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid id")))
194204
return
195205
}
206+
id := uint(id64)
207+
commentModel, err := FindOneComment(&CommentModel{Model: gorm.Model{ID: id}})
208+
if err == nil {
209+
// Comment exists, check authorization
210+
myUserModel := c.MustGet("my_user_model").(users.UserModel)
211+
articleUserModel := GetArticleUserModel(myUserModel)
212+
if commentModel.AuthorID != articleUserModel.ID {
213+
c.JSON(http.StatusForbidden, common.NewError("comment", errors.New("you are not the author")))
214+
return
215+
}
216+
}
217+
// Delete regardless of existence (idempotent)
218+
DeleteCommentModel([]uint{id})
196219
c.Status(http.StatusOK)
197220
}
198221

articles/unit_test.go

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ var articleRequestTests = []struct {
490490
"/api/articles/updated-title/comments",
491491
"POST",
492492
`{"comment":{"body":"Test comment body"}}`,
493-
http.StatusOK,
493+
http.StatusCreated,
494494
`"body":"Test comment body"`,
495495
"create comment should succeed",
496496
},
@@ -737,13 +737,13 @@ func TestCreateCommentRequiredFields(t *testing.T) {
737737
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Missing body should return 422")
738738
asserts.Contains(w.Body.String(), "Body", "Error should mention Body field")
739739

740-
// Test valid comment creation - should return 200 per OpenAPI spec
740+
// Test valid comment creation - should return 201 per spec
741741
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/articles/%s/comments", article.Slug), bytes.NewBufferString(`{"comment":{"body":"Test comment body"}}`))
742742
req.Header.Set("Content-Type", "application/json")
743743
HeaderTokenMock(req, user.ID)
744744
w = httptest.NewRecorder()
745745
r.ServeHTTP(w, req)
746-
asserts.Equal(http.StatusOK, w.Code, "Valid comment should return 200")
746+
asserts.Equal(http.StatusCreated, w.Code, "Valid comment should return 201")
747747
asserts.Contains(w.Body.String(), `"comment"`, "Response should contain comment")
748748
}
749749

@@ -1516,3 +1516,102 @@ func TestMain(m *testing.M) {
15161516
common.TestDBFree(test_db)
15171517
os.Exit(exitVal)
15181518
}
1519+
1520+
func TestArticleUpdateAuthorizationForbidden(t *testing.T) {
1521+
asserts := assert.New(t)
1522+
1523+
r := setupRouter()
1524+
user1 := createTestUser()
1525+
user2 := createTestUser()
1526+
1527+
// Create an article with user1
1528+
articleUserModel := GetArticleUserModel(user1)
1529+
slug := fmt.Sprintf("auth-test-article-%d", common.RandInt())
1530+
article := ArticleModel{
1531+
Slug: slug,
1532+
Title: "Auth Test Article",
1533+
Description: "Test Description",
1534+
Body: "Test Body",
1535+
Author: articleUserModel,
1536+
AuthorID: articleUserModel.ID,
1537+
}
1538+
SaveOne(&article)
1539+
1540+
// Try to update with user2 (should fail with 403)
1541+
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/articles/%s", slug), bytes.NewBufferString(`{"article":{"body":"Hacked Body"}}`))
1542+
req.Header.Set("Content-Type", "application/json")
1543+
HeaderTokenMock(req, user2.ID)
1544+
w := httptest.NewRecorder()
1545+
r.ServeHTTP(w, req)
1546+
1547+
asserts.Equal(http.StatusForbidden, w.Code, "Non-author update should return 403")
1548+
asserts.Contains(w.Body.String(), "you are not the author", "Error should mention authorization")
1549+
}
1550+
1551+
func TestArticleDeleteAuthorizationForbidden(t *testing.T) {
1552+
asserts := assert.New(t)
1553+
1554+
r := setupRouter()
1555+
user1 := createTestUser()
1556+
user2 := createTestUser()
1557+
1558+
// Create an article with user1
1559+
articleUserModel := GetArticleUserModel(user1)
1560+
slug := fmt.Sprintf("auth-delete-test-%d", common.RandInt())
1561+
article := ArticleModel{
1562+
Slug: slug,
1563+
Title: "Auth Delete Test",
1564+
Description: "Test Description",
1565+
Body: "Test Body",
1566+
Author: articleUserModel,
1567+
AuthorID: articleUserModel.ID,
1568+
}
1569+
SaveOne(&article)
1570+
1571+
// Try to delete with user2 (should fail with 403)
1572+
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s", slug), nil)
1573+
HeaderTokenMock(req, user2.ID)
1574+
w := httptest.NewRecorder()
1575+
r.ServeHTTP(w, req)
1576+
1577+
asserts.Equal(http.StatusForbidden, w.Code, "Non-author delete should return 403")
1578+
}
1579+
1580+
func TestCommentDeleteAuthorizationForbidden(t *testing.T) {
1581+
asserts := assert.New(t)
1582+
1583+
r := setupRouter()
1584+
user1 := createTestUser()
1585+
user2 := createTestUser()
1586+
1587+
// Create article and comment with user1
1588+
articleUserModel := GetArticleUserModel(user1)
1589+
slug := fmt.Sprintf("comment-auth-test-%d", common.RandInt())
1590+
article := ArticleModel{
1591+
Slug: slug,
1592+
Title: "Comment Auth Test",
1593+
Description: "Test Description",
1594+
Body: "Test Body",
1595+
Author: articleUserModel,
1596+
AuthorID: articleUserModel.ID,
1597+
}
1598+
SaveOne(&article)
1599+
1600+
comment := CommentModel{
1601+
Article: article,
1602+
ArticleID: article.ID,
1603+
Author: articleUserModel,
1604+
AuthorID: articleUserModel.ID,
1605+
Body: "Test comment",
1606+
}
1607+
SaveOne(&comment)
1608+
1609+
// Try to delete comment with user2 (should fail with 403)
1610+
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s/comments/%d", slug, comment.ID), nil)
1611+
HeaderTokenMock(req, user2.ID)
1612+
w := httptest.NewRecorder()
1613+
r.ServeHTTP(w, req)
1614+
1615+
asserts.Equal(http.StatusForbidden, w.Code, "Non-author comment delete should return 403")
1616+
asserts.Contains(w.Body.String(), "you are not the author", "Error should mention authorization")
1617+
}

common/unit_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,70 @@ func TestNewError(t *testing.T) {
195195
assert.Equal(expectedError,
196196
commenError.Errors, "commenError should have right error info")
197197
}
198+
199+
func TestDatabaseDirCreation(t *testing.T) {
200+
asserts := assert.New(t)
201+
202+
// Test directory creation in Init
203+
origDBPath := os.Getenv("DB_PATH")
204+
defer os.Setenv("DB_PATH", origDBPath)
205+
206+
// Create a temp dir path
207+
tempDir := "./tmp/test_nested/db"
208+
os.Setenv("DB_PATH", tempDir+"/test.db")
209+
210+
// Clean up before test
211+
os.RemoveAll("./tmp/test_nested")
212+
213+
// Init should create the directory
214+
db := Init()
215+
sqlDB, _ := db.DB()
216+
asserts.NoError(sqlDB.Ping(), "DB should be created in nested directory")
217+
218+
// Clean up after test
219+
sqlDB.Close()
220+
os.RemoveAll("./tmp/test_nested")
221+
}
222+
223+
func TestTestDatabaseDirCreation(t *testing.T) {
224+
asserts := assert.New(t)
225+
226+
// Test directory creation in TestDBInit
227+
origTestDBPath := os.Getenv("TestDB_PATH")
228+
defer os.Setenv("TestDB_PATH", origTestDBPath)
229+
230+
// Create a temp dir path
231+
tempDir := "./tmp/test_nested_testdb"
232+
os.Setenv("TestDB_PATH", tempDir+"/test.db")
233+
234+
// Clean up before test
235+
os.RemoveAll(tempDir)
236+
237+
// TestDBInit should create the directory
238+
db := TestDBInit()
239+
sqlDB, _ := db.DB()
240+
asserts.NoError(sqlDB.Ping(), "Test DB should be created in nested directory")
241+
242+
// Clean up after test
243+
TestDBFree(db)
244+
os.RemoveAll(tempDir)
245+
}
246+
247+
func TestDatabaseWithCurrentDirectory(t *testing.T) {
248+
asserts := assert.New(t)
249+
250+
// Test with simple filename (no directory)
251+
origDBPath := os.Getenv("DB_PATH")
252+
defer os.Setenv("DB_PATH", origDBPath)
253+
254+
os.Setenv("DB_PATH", "test_simple.db")
255+
256+
// Init should work without directory creation
257+
db := Init()
258+
sqlDB, _ := db.DB()
259+
asserts.NoError(sqlDB.Ping(), "DB should be created in current directory")
260+
261+
// Clean up
262+
sqlDB.Close()
263+
os.Remove("test_simple.db")
264+
}

0 commit comments

Comments
 (0)