fix: code principles audit — correctness, security, testability

- Add rows.Err() checks after all scan loops (entities, tags, resolve)
- Surface time.Parse errors instead of silently discarding
- Extract entityRow scan helper to eliminate Get/List duplication
- Cap request body at 1MB via MaxBytesReader
- Stop leaking internal errors to API clients (log server-side only)
- Block javascript: URIs in link card open button (XSS)
- Fix all go vet failures in api_test.go (unchecked http errors)
- Add tests for display package, generateCardData, absorb-source-card
- Run go mod tidy to fix direct/indirect dep markers
This commit is contained in:
2026-05-14 17:41:30 -04:00
parent e708ea5c13
commit 6278cb1022
12 changed files with 500 additions and 104 deletions
+112 -41
View File
@@ -25,15 +25,22 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
return srv, store
}
func postJSON(srv *httptest.Server, path string, body any) *http.Response {
b, _ := json.Marshal(body)
resp, _ := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
return resp
}
func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []string) EntityResponse {
t.Helper()
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": body,
"tags": tags,
})
@@ -49,7 +56,7 @@ func createTestEntity(t *testing.T, srv *httptest.Server, body string, tags []st
func TestCreateEntity_Note(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "test note",
"tags": []string{"demo"},
})
@@ -76,7 +83,7 @@ func TestCreateEntity_Note(t *testing.T) {
func TestCreateEntity_MissingBody(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{})
resp := postJSON(t, srv, "/api/entities", map[string]any{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -93,7 +100,7 @@ func TestCreateEntity_MissingBody(t *testing.T) {
func TestCreateEntity_InvalidGlyph(t *testing.T) {
srv, _ := testServer(t)
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "test",
"glyph": "invalid",
})
@@ -108,7 +115,10 @@ func TestGetEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "test", nil)
resp, _ := http.Get(srv.URL + "/api/entities/" + created.ID)
resp, err := http.Get(srv.URL + "/api/entities/" + created.ID)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -125,7 +135,10 @@ func TestGetEntity_Success(t *testing.T) {
func TestGetEntity_NotFound(t *testing.T) {
srv, _ := testServer(t)
resp, _ := http.Get(srv.URL + "/api/entities/NONEXISTENT")
resp, err := http.Get(srv.URL + "/api/entities/NONEXISTENT")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
@@ -138,7 +151,10 @@ func TestListEntities_Default(t *testing.T) {
createTestEntity(t, srv, "one", nil)
createTestEntity(t, srv, "two", nil)
resp, _ := http.Get(srv.URL + "/api/entities")
resp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entities []EntityResponse
@@ -153,7 +169,10 @@ func TestListEntities_FilterTag(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"home"})
resp, _ := http.Get(srv.URL + "/api/entities?tag=ops")
resp, err := http.Get(srv.URL + "/api/entities?tag=ops")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entities []EntityResponse
@@ -167,13 +186,16 @@ func TestListEntities_CardsOnly(t *testing.T) {
srv, _ := testServer(t)
createTestEntity(t, srv, "fluid", nil)
resp := postJSON(srv, "/api/entities", map[string]any{
resp := postJSON(t, srv, "/api/entities", map[string]any{
"body": "card",
"card_type": "snippet",
})
resp.Body.Close()
resp, _ = http.Get(srv.URL + "/api/entities?cards_only=true")
resp, err := http.Get(srv.URL + "/api/entities?cards_only=true")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entities []EntityResponse
@@ -189,12 +211,18 @@ func TestListEntities_Pagination(t *testing.T) {
createTestEntity(t, srv, "note", nil)
}
resp, _ := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
resp, err := http.Get(srv.URL + "/api/entities?limit=2&offset=0")
if err != nil {
t.Fatal(err)
}
var page1 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page1)
resp.Body.Close()
resp, _ = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
if err != nil {
t.Fatal(err)
}
var page2 []EntityResponse
json.NewDecoder(resp.Body).Decode(&page2)
resp.Body.Close()
@@ -211,10 +239,16 @@ func TestUpdateEntity_Body(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "old", nil)
req, _ := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
req, err := http.NewRequest("PUT", srv.URL+"/api/entities/"+created.ID, bytes.NewReader(
mustJSON(map[string]any{"body": "new"})))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -233,8 +267,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
created := createTestEntity(t, srv, "doomed", nil)
// Soft delete
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
resp, _ := http.DefaultClient.Do(req)
req, err := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var delResp DeleteResponse
json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close()
@@ -246,7 +286,14 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
}
// Hard delete
resp, _ = http.DefaultClient.Do(req)
req, err = http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
if err != nil {
t.Fatal(err)
}
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&delResp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -257,7 +304,10 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
}
// Gone
resp, _ = http.Get(srv.URL + "/api/entities/" + created.ID)
resp, err = http.Get(srv.URL + "/api/entities/" + created.ID)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after hard delete, got %d", resp.StatusCode)
@@ -268,7 +318,7 @@ func TestPromoteEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
})
defer resp.Body.Close()
@@ -288,11 +338,11 @@ func TestPromoteEntity_AlreadyPromoted(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "template",
})
defer resp.Body.Close()
@@ -312,7 +362,7 @@ func TestPromoteEntity_InvalidType(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "bogus",
})
defer resp.Body.Close()
@@ -326,11 +376,11 @@ func TestDemoteEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
postJSON(srv, "/api/entities/"+created.ID+"/promote", map[string]any{
postJSON(t, srv, "/api/entities/"+created.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -348,7 +398,7 @@ func TestDemoteEntity_AlreadyFluid(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/demote", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/demote", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
@@ -360,7 +410,7 @@ func TestUseEntity_Success(t *testing.T) {
srv, _ := testServer(t)
created := createTestEntity(t, srv, "trick", nil)
resp := postJSON(srv, "/api/entities/"+created.ID+"/use", nil)
resp := postJSON(t, srv, "/api/entities/"+created.ID+"/use", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -379,7 +429,10 @@ func TestListTags_WithCounts(t *testing.T) {
createTestEntity(t, srv, "a", []string{"ops"})
createTestEntity(t, srv, "b", []string{"ops", "nginx"})
resp, _ := http.Get(srv.URL + "/api/tags")
resp, err := http.Get(srv.URL + "/api/tags")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var tags []TagResponse
@@ -391,14 +444,23 @@ func TestListTags_WithCounts(t *testing.T) {
func TestCORS_DevMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
store, _ := db.Open(path)
store, err := db.Open(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
router := NewRouter(store, true)
srv := httptest.NewServer(router)
defer srv.Close()
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req)
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
@@ -412,8 +474,14 @@ func TestCORS_DevMode(t *testing.T) {
func TestCORS_ProdMode(t *testing.T) {
srv, _ := testServer(t) // devMode=false
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
resp, _ := http.DefaultClient.Do(req)
req, err := http.NewRequest("OPTIONS", srv.URL+"/api/entities", nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
@@ -426,7 +494,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
target := createTestEntity(t, srv, "target body", []string{"ops"})
source := createTestEntity(t, srv, "source body", []string{"ops", "infra"})
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
"source_id": source.ID,
})
defer resp.Body.Close()
@@ -445,7 +513,10 @@ func TestAbsorbEntity_Success(t *testing.T) {
}
// Source should be soft-deleted (not in default list)
listResp, _ := http.Get(srv.URL + "/api/entities")
listResp, err := http.Get(srv.URL + "/api/entities")
if err != nil {
t.Fatal(err)
}
var entities []EntityResponse
json.NewDecoder(listResp.Body).Decode(&entities)
listResp.Body.Close()
@@ -461,11 +532,11 @@ func TestAbsorbEntity_TargetCrystallized(t *testing.T) {
target := createTestEntity(t, srv, "target", nil)
source := createTestEntity(t, srv, "source", nil)
postJSON(srv, "/api/entities/"+target.ID+"/promote", map[string]any{
postJSON(t, srv, "/api/entities/"+target.ID+"/promote", map[string]any{
"card_type": "snippet",
}).Body.Close()
resp := postJSON(srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+target.ID+"/absorb", map[string]any{
"source_id": source.ID,
})
defer resp.Body.Close()
@@ -485,7 +556,7 @@ func TestAbsorbEntity_SameEntity(t *testing.T) {
srv, _ := testServer(t)
e := createTestEntity(t, srv, "self", nil)
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{
"source_id": e.ID,
})
defer resp.Body.Close()
@@ -499,7 +570,7 @@ func TestAbsorbEntity_MissingSourceID(t *testing.T) {
srv, _ := testServer(t)
e := createTestEntity(t, srv, "target", nil)
resp := postJSON(srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
resp := postJSON(t, srv, "/api/entities/"+e.ID+"/absorb", map[string]any{})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
+14 -14
View File
@@ -100,7 +100,7 @@ func listEntities(store *db.Store) http.HandlerFunc {
entities, err := store.List(p)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
@@ -151,7 +151,7 @@ func createEntity(store *db.Store) http.HandlerFunc {
}
if err := store.Create(e); err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
@@ -168,7 +168,7 @@ func getEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -215,13 +215,13 @@ func updateEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -241,7 +241,7 @@ func deleteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
label := "soft"
@@ -279,13 +279,13 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -305,13 +305,13 @@ func demoteEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_demote", "entity is already fluid")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -349,13 +349,13 @@ func absorbEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_absorb", "target is crystallized — demote first")
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
@@ -371,13 +371,13 @@ func useEntity(store *db.Store) http.HandlerFunc {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
e, err := store.Get(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, entityToResponse(e))
+9
View File
@@ -2,12 +2,15 @@ package api
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/lerko/nib/internal/db"
)
const maxBodySize = 1 << 20 // 1 MB
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
@@ -41,6 +44,7 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "malformed JSON: "+err.Error())
return false
@@ -48,6 +52,11 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
return true
}
func writeInternalError(w http.ResponseWriter, err error) {
log.Printf("internal error: %v", err)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
func entityToResponse(e *db.Entity) EntityResponse {
resp := EntityResponse{
ID: e.ID,
+1 -1
View File
@@ -15,7 +15,7 @@ func listTags(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tags, err := store.ListTags()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
writeInternalError(w, err)
return
}