feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
User-facing Feedback screen (Settings -> Info, registered accounts only): a message (<=1024 runes) plus one optional attachment, an anti-spam gate (one unreviewed message at a time), and the operator's inline reply with a Settings/Info badge. Server-rendered admin console section (/_gm/feedback): unread/read/archived queue with per-user search, detail with read/reply/ archive/delete/delete-all, safe attachment serving (nosniff, images inline via <img>, others download-only). Introduces account_roles, the first per-account role table; feedback_banned blocks only feedback submission, granted/revoked from /users and the delete-with-block action. - migration 00004_feedback (feedback_messages + account_roles) + jetgen - backend internal/feedback (store+service), internal/account/roles.go - wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest, is_guest via session resolve) -> guest_forbidden before any backend call - reply push reuses NotificationEvent with a new admin_reply sub-kind - UI: /feedback route + screen, attachment picker, badge, channel detection, i18n - tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e - docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package feedback
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllowedAttachment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
want bool
|
||||
}{
|
||||
{"png image", "shot.png", true},
|
||||
{"jpeg upper-case ext", "Photo.JPG", true},
|
||||
{"pdf doc", "report.pdf", true},
|
||||
{"archive 7z", "logs.7z", true},
|
||||
{"doc with dotted name", "my.notes.docx", true},
|
||||
{"disallowed exe", "evil.exe", false},
|
||||
{"disallowed svg (xss vector)", "x.svg", false},
|
||||
{"disallowed html", "x.html", false},
|
||||
{"no extension", "README", false},
|
||||
{"empty name", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AllowedAttachment(tt.file); got != tt.want {
|
||||
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageAndContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
isImage bool
|
||||
ctype string
|
||||
}{
|
||||
{"a.png", true, "image/png"},
|
||||
{"a.jpg", true, "image/jpeg"},
|
||||
{"a.jpeg", true, "image/jpeg"},
|
||||
{"a.webp", true, "image/webp"},
|
||||
{"a.gif", true, "image/gif"},
|
||||
{"a.pdf", false, "application/octet-stream"},
|
||||
{"a.zip", false, "application/octet-stream"},
|
||||
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
|
||||
{"noext", false, "application/octet-stream"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.file, func(t *testing.T) {
|
||||
if got := IsImage(tt.file); got != tt.isImage {
|
||||
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
|
||||
}
|
||||
if got := ContentType(tt.file); got != tt.ctype {
|
||||
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"telegram", "telegram"},
|
||||
{"ios", "ios"},
|
||||
{"android", "android"},
|
||||
{"web", "web"},
|
||||
{" iOS ", "ios"}, // trimmed + lower-cased
|
||||
{"TELEGRAM", "telegram"},
|
||||
{"", "web"}, // unknown -> web
|
||||
{"windows", "web"}, // unknown -> web
|
||||
{"'; DROP", "web"}, // junk -> web
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
if got := normalizeChannel(tt.in); got != tt.want {
|
||||
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user