mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
718 lines
26 KiB
Go
718 lines
26 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/owncord/server/api"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/service"
|
|
)
|
|
|
|
// buildProfileRouter returns a chi router with profile routes mounted.
|
|
func buildProfileRouter(database *db.DB) http.Handler {
|
|
r := chi.NewRouter()
|
|
limiter := auth.NewRateLimiter()
|
|
svc := service.New(database, limiter)
|
|
api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil)
|
|
return r
|
|
}
|
|
|
|
// profileCreateToken creates a user and session, returning the raw token.
|
|
func profileCreateToken(t *testing.T, database *db.DB, username string, roleID int) string {
|
|
t.Helper()
|
|
uid, err := database.CreateUser(context.Background(), username, mustHash(t), roleID)
|
|
if err != nil {
|
|
t.Fatalf("CreateUser(%s): %v", username, err)
|
|
}
|
|
token, err := auth.GenerateToken()
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken: %v", err)
|
|
}
|
|
expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
|
|
_, err = database.ExecContext(context.Background(),
|
|
"INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)",
|
|
uid, auth.HashToken(token), "TestAgent", "127.0.0.1", expiresAt,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert session: %v", err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
// mustHash returns a bcrypt hash of a standard test password.
|
|
func mustHash(t *testing.T) string {
|
|
t.Helper()
|
|
h, err := auth.HashPassword("securePass1")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// patchJSON sends a PATCH request with JSON body and auth token.
|
|
func patchJSON(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPatch, path, bytes.NewReader(raw))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// putJSON sends a PUT request with JSON body and auth token.
|
|
func putJSON(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
raw, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(raw))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// profileDelete sends a DELETE request with an auth token (no body).
|
|
func profileDelete(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// ─── PATCH /api/v1/users/me ──────────────────────────────────────────────────
|
|
|
|
func TestUpdateProfile_Success(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "patchuser", 4)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "newname",
|
|
"avatar": "https://example.com/av.png",
|
|
})
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["username"] != "newname" {
|
|
t.Errorf("username = %v, want 'newname'", resp["username"])
|
|
}
|
|
}
|
|
|
|
func TestUpdateProfile_EmptyUsername(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "emptyuser", 4)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "",
|
|
})
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// OC-0100: a rename must canonicalize identically to how login looks the
|
|
// username up. A bare bluemonday sanitizer.Sanitize call HTML-escapes
|
|
// survivor punctuation instead of leaving it as typed, so a name with an
|
|
// apostrophe would be persisted as e.g. "O'Brien" — unreachable by the
|
|
// literal name on the next login.
|
|
func TestUpdateProfile_UsernameWithApostropheIsNotEscaped(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "apostropheuser", 4)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "O'Brien",
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["username"] != "O'Brien" {
|
|
t.Errorf("username = %v, want %q (must not be HTML-escaped)", resp["username"], "O'Brien")
|
|
}
|
|
|
|
// The row itself must be reachable by the literal name — that is exactly
|
|
// what a future login looks up.
|
|
u, err := database.GetUserByUsername(context.Background(), "O'Brien")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("GetUserByUsername(%q) = %v, %v — rename escaped the username and would lock the account out on next login", "O'Brien", u, err)
|
|
}
|
|
}
|
|
|
|
// OC-0151: handleUpdateProfile is the same call in the same order as
|
|
// registerReadRequest — service.SanitizeText (the fixpoint sanitizer) runs
|
|
// on the raw username before auth.ValidateUsername's 32-rune cap. Since
|
|
// sanitizeToFixpoint's cost is quadratic in input length, an authenticated
|
|
// caller can still pin a core for hundreds of milliseconds (and much longer
|
|
// at larger sizes) with one PATCH before any bound is applied. The fix must
|
|
// reject an oversized username on a cheap byte-length check before
|
|
// sanitizing, so the rejection is near-instant regardless of payload size.
|
|
func TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "patchvictim", 4)
|
|
|
|
// Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's
|
|
// doc comment for why this shape is quadratic to sanitize.
|
|
hugeUsername := "&" + strings.Repeat("amp;", 4000) + "lt;"
|
|
|
|
start := time.Now()
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": hugeUsername,
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("UpdateProfile oversized username status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// A guard that runs before sanitizing rejects in well under a
|
|
// millisecond; the pre-fix code spends ~200ms in sanitizeToFixpoint on
|
|
// this payload before it ever reaches auth.ValidateUsername's length
|
|
// check. 150ms gives generous margin over noise while still being far
|
|
// below the unguarded cost.
|
|
if elapsed > 150*time.Millisecond {
|
|
t.Errorf("UpdateProfile oversized username took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed)
|
|
}
|
|
}
|
|
|
|
// OC-0192: same story as OC-0151 above, but for the avatar field — the
|
|
// service.SanitizeText call in the avatar branch has no byte-length guard at
|
|
// all, unlike the username field just above it. validateAvatarURL's
|
|
// maxAvatarURLLen check never gets a chance to reject a huge payload cheaply,
|
|
// because the fixpoint sanitizer already spent its (quadratic) cost on it
|
|
// first. The fix must reject an oversized avatar on a cheap byte-length
|
|
// check before sanitizing, so the rejection is near-instant regardless of
|
|
// payload size.
|
|
func TestUpdateProfile_OversizedAvatarRejectedBeforeSanitizing(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "avatarvictim", 4)
|
|
|
|
// Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's
|
|
// doc comment for why this shape is quadratic to sanitize.
|
|
hugeAvatar := "&" + strings.Repeat("amp;", 4000) + "lt;"
|
|
|
|
start := time.Now()
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "avatarvictim",
|
|
"avatar": hugeAvatar,
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("UpdateProfile oversized avatar status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// See TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing for the
|
|
// rationale behind this bound: a guard that runs before sanitizing
|
|
// rejects in well under a millisecond, while the pre-fix code spends over
|
|
// 150ms in sanitizeToFixpoint on this payload before validateAvatarURL's
|
|
// length check ever runs.
|
|
if elapsed > 150*time.Millisecond {
|
|
t.Errorf("UpdateProfile oversized avatar took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed)
|
|
}
|
|
}
|
|
|
|
// OC-0197: display_name is validated (validateDisplayName) against the raw
|
|
// JSON string, before the fixpoint sanitizer's outer html.UnescapeString
|
|
// ever runs (that happens later, inside UserService.UpdateProfile's
|
|
// cleanText call). So an entity-encoded control or bidi character like
|
|
// "‮" sails through validateDisplayName as harmless ASCII, and is only
|
|
// turned into the real U+202E RIGHT-TO-LEFT OVERRIDE character afterwards,
|
|
// on its way into storage. TestUpdateProfile_RejectsBadDisplayName
|
|
// (avatar_handler_test.go) shows the literal character is correctly
|
|
// rejected; this is the entity-encoded bypass of that same guard — the fix
|
|
// is to sanitize display_name before validating it, the same order the
|
|
// username field above already uses.
|
|
func TestUpdateProfile_RejectsEntityEncodedBidiOverrideInDisplayName(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "dnentity", 4)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "dnentity",
|
|
"display_name": "ada‮gnp.exe",
|
|
})
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("entity-encoded bidi override display_name status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Regardless of what the handler answered, the stored row must never end
|
|
// up holding a real bidi override character smuggled in via the entity
|
|
// encoding — that is the actual harm (it renders wherever the username
|
|
// does, in every connected client, once broadcast).
|
|
u, err := database.GetUserByUsername(context.Background(), "dnentity")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("GetUserByUsername: %v, %v", u, err)
|
|
}
|
|
if u.DisplayName != nil && strings.ContainsRune(*u.DisplayName, '\u202e') {
|
|
t.Errorf("stored display_name = %q, contains a real U+202E bidi override smuggled past validateDisplayName via HTML entity", *u.DisplayName)
|
|
}
|
|
}
|
|
|
|
// OC-0180: the avatar branch must canonicalize with the same fixpoint
|
|
// sanitizer (service.SanitizeText) as the username path above it, not the
|
|
// bare bluemonday sanitizer.Sanitize — Sanitize's output is always
|
|
// HTML-escaped, so a legitimate avatar URL with more than one query
|
|
// parameter gets its "&" separators rewritten to "&" and is persisted
|
|
// (and later served to every client) as a broken URL.
|
|
func TestUpdateProfile_AvatarQueryStringIsNotEscaped(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "avatarqsuser", 4)
|
|
|
|
const avatarURL = "https://www.gravatar.com/avatar/abc?s=256&d=identicon"
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "avatarqsuser",
|
|
"avatar": avatarURL,
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["avatar"] != avatarURL {
|
|
t.Errorf("avatar = %v, want %q (must not be HTML-escaped)", resp["avatar"], avatarURL)
|
|
}
|
|
|
|
u, err := database.GetUserByUsername(context.Background(), "avatarqsuser")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("GetUserByUsername: %v, %v", u, err)
|
|
}
|
|
if u.Avatar == nil || *u.Avatar != avatarURL {
|
|
t.Errorf("stored avatar = %v, want %q (must not be HTML-escaped)", u.Avatar, avatarURL)
|
|
}
|
|
}
|
|
|
|
func TestUpdateProfile_UsernameTaken(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
profileCreateToken(t, database, "takenname", 4)
|
|
token := profileCreateToken(t, database, "wannatake", 4)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "takenname",
|
|
})
|
|
|
|
if rr.Code != http.StatusConflict {
|
|
t.Errorf("status = %d, want 409; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateProfile_Unauthorized(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", "badtoken", map[string]string{
|
|
"username": "hacker",
|
|
})
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── PUT /api/v1/users/me/password ──────────────────────────────────────────
|
|
|
|
func TestChangePassword_Success(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "pwuser", 4)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
|
|
"old_password": "securePass1",
|
|
"new_password": "newSecure2",
|
|
})
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// BUG-108: Password change must revoke other sessions.
|
|
func TestChangePassword_RevokesOtherSessions(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
// Create user with two sessions.
|
|
token1 := profileCreateToken(t, database, "pw-revoke", 4)
|
|
user, _ := database.GetUserByUsername(context.Background(), "pw-revoke")
|
|
|
|
// Create a second session for the same user.
|
|
token2, _ := auth.GenerateToken()
|
|
expiresAt := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
|
|
_, _ = database.ExecContext(context.Background(),
|
|
"INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)",
|
|
user.ID, auth.HashToken(token2), "OtherDevice", "10.0.0.1", expiresAt,
|
|
)
|
|
|
|
// Change password using token1.
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token1, map[string]string{
|
|
"old_password": "securePass1",
|
|
"new_password": "newSecure2",
|
|
})
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// token1 (current session) should still work.
|
|
sess1, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token1))
|
|
if sess1 == nil {
|
|
t.Error("current session should survive password change")
|
|
}
|
|
|
|
// token2 (other session) should be revoked.
|
|
sess2, _ := database.GetSessionByTokenHash(context.Background(), auth.HashToken(token2))
|
|
if sess2 != nil {
|
|
t.Error("other session should be revoked after password change")
|
|
}
|
|
}
|
|
|
|
func TestChangePassword_WrongOldPassword(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "wrongpw", 4)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
|
|
"old_password": "wrongPassword1",
|
|
"new_password": "newSecure2",
|
|
})
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChangePassword_WeakNewPassword(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "weakpw", 4)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
|
|
"old_password": "securePass1",
|
|
"new_password": "short",
|
|
})
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChangePassword_SamePassword(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "samepw", 4)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
|
|
"old_password": "securePass1",
|
|
"new_password": "securePass1",
|
|
})
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── GET /api/v1/users/me/sessions ──────────────────────────────────────────
|
|
|
|
func TestListSessions_Success(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "sessuser", 4)
|
|
|
|
rr := getWithToken(t, router, "/api/v1/users/me/sessions", token)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Sessions []map[string]any `json:"sessions"`
|
|
}
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(resp.Sessions) == 0 {
|
|
t.Error("expected at least 1 session (the current one)")
|
|
}
|
|
|
|
// Verify is_current flag is present.
|
|
found := false
|
|
for _, s := range resp.Sessions {
|
|
if isCurrent, ok := s["is_current"]; ok && isCurrent == true {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("no session has is_current=true")
|
|
}
|
|
}
|
|
|
|
func TestListSessions_Unauthorized(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
rr := getWithToken(t, router, "/api/v1/users/me/sessions", "badtoken")
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── DELETE /api/v1/users/me/sessions/:id ───────────────────────────────────
|
|
|
|
func TestRevokeSession_Success(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "revoke", 4)
|
|
|
|
// Create a second session to revoke.
|
|
user, _ := database.GetUserByUsername(context.Background(), "revoke")
|
|
secondSessID, _ := database.CreateSession(context.Background(), user.ID, auth.HashToken("second-tok"), "Firefox", "1.2.3.4")
|
|
|
|
rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", secondSessID), token)
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_NotFound(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "revokenf", 4)
|
|
|
|
rr := profileDelete(t, router, "/api/v1/users/me/sessions/99999", token)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_OtherUsersSession(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "revokeother", 4)
|
|
|
|
// Create another user with a session.
|
|
otherUID, _ := database.CreateUser(context.Background(), "victim", mustHash(t), 4)
|
|
otherSessID, _ := database.CreateSession(context.Background(), otherUID, auth.HashToken("victim-tok"), "Safari", "9.8.7.6")
|
|
|
|
rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", otherSessID), token)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404 (should not reveal other user's session); body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_CurrentSession(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "revokeself", 4)
|
|
|
|
// Find the current session ID.
|
|
user, _ := database.GetUserByUsername(context.Background(), "revokeself")
|
|
sessions, _ := database.ListUserSessions(context.Background(), user.ID)
|
|
if len(sessions) == 0 {
|
|
t.Fatal("expected at least 1 session")
|
|
}
|
|
|
|
rr := profileDelete(t, router, fmt.Sprintf("/api/v1/users/me/sessions/%d", sessions[0].ID), token)
|
|
|
|
// Revoking own session is allowed.
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("status = %d, want 204; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── PATCH /api/v1/users/me — identity_public_key (F3 voice E2EE TOFU) ───────
|
|
|
|
func TestUpdateProfile_PublishIdentityKey(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "idkeyuser", 4)
|
|
|
|
key := "BPZ8bfkPz8B64iDeNtItYkEy0123456789abcdef+/=="
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "idkeyuser",
|
|
"identity_public_key": key,
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
u, err := database.GetUserByUsername(context.Background(), "idkeyuser")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
}
|
|
if u.IdentityPublicKey == nil || *u.IdentityPublicKey != key {
|
|
t.Errorf("IdentityPublicKey = %v, want %q", u.IdentityPublicKey, key)
|
|
}
|
|
}
|
|
|
|
func TestUpdateProfile_IdentityKeyOmitted_Unchanged(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "idkeykeep", 4)
|
|
|
|
key := "a2VlcHRoaXNrZXk="
|
|
u, err := database.GetUserByUsername(context.Background(), "idkeykeep")
|
|
if err != nil || u == nil {
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
}
|
|
if err := database.UpdateUserIdentityKey(context.Background(), u.ID, &key); err != nil {
|
|
t.Fatalf("UpdateUserIdentityKey: %v", err)
|
|
}
|
|
|
|
// PATCH without identity_public_key must not clear the stored key.
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "idkeykeep",
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
after, err := database.GetUserByID(context.Background(), u.ID)
|
|
if err != nil || after == nil {
|
|
t.Fatalf("GetUserByID: %v", err)
|
|
}
|
|
if after.IdentityPublicKey == nil || *after.IdentityPublicKey != key {
|
|
t.Errorf("IdentityPublicKey = %v, want %q (unchanged)", after.IdentityPublicKey, key)
|
|
}
|
|
}
|
|
|
|
func TestUpdateProfile_IdentityKeyInvalid(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
cases := []struct {
|
|
name string
|
|
key string
|
|
}{
|
|
{"not base64", "!!!not-base64!!!"},
|
|
{"url-safe alphabet", "abc-_def"},
|
|
{"too large", strings.Repeat("A", 132)},
|
|
{"empty", ""},
|
|
}
|
|
for i, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
token := profileCreateToken(t, database, fmt.Sprintf("idkeybad%d", i), 4)
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": fmt.Sprintf("idkeybad%d", i),
|
|
"identity_public_key": tc.key,
|
|
})
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── GET /api/v1/users/me/sessions with an API-token principal ────────────────
|
|
|
|
func TestListSessions_APITokenPrincipal(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
// One login session exists, but the caller authenticates with an API
|
|
// token (nil SessionKey). The sibling DELETE works for this principal;
|
|
// the list must too, with no session marked current.
|
|
_ = profileCreateToken(t, database, "apisessions", 4)
|
|
user, _ := database.GetUserByUsername(context.Background(), "apisessions")
|
|
if user == nil {
|
|
t.Fatal("user not found")
|
|
}
|
|
apiTok, err := auth.GenerateToken()
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken: %v", err)
|
|
}
|
|
if _, err := database.CreateAPIToken(context.Background(), user.ID, auth.HashToken(apiTok), "ci", nil); err != nil {
|
|
t.Fatalf("CreateAPIToken: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/sessions", nil)
|
|
req.Header.Set("Authorization", "Bearer "+apiTok)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("sessions list via API token: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp struct {
|
|
Sessions []struct {
|
|
ID int64 `json:"id"`
|
|
IsCurrent bool `json:"is_current"`
|
|
} `json:"sessions"`
|
|
}
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if len(resp.Sessions) != 1 {
|
|
t.Fatalf("sessions = %d, want 1", len(resp.Sessions))
|
|
}
|
|
if resp.Sessions[0].IsCurrent {
|
|
t.Error("API-token principal must not mark any session as current")
|
|
}
|
|
}
|
|
|
|
// ─── Rate-limit bucket isolation ─────────────────────────────────────────────
|
|
|
|
func TestRateLimit_ProfileUpdatesDoNotConsumePasswordBudget(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "bucketuser", 4)
|
|
|
|
// Five profile PATCHes (limit 10/min). If the password endpoint shared
|
|
// the same bare-IP bucket, its 5/min budget would now read as exhausted
|
|
// with zero password attempts made.
|
|
for i := range 5 {
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "bucketuser",
|
|
})
|
|
if rr.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("profile PATCH %d unexpectedly rate limited", i)
|
|
}
|
|
}
|
|
|
|
raw, _ := json.Marshal(map[string]string{
|
|
"old_password": "wrong-on-purpose",
|
|
"new_password": "NewSecurePass1!",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/users/me/password", bytes.NewReader(raw))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code == http.StatusTooManyRequests {
|
|
t.Fatal("password change 429'd with zero password attempts made — unrelated endpoints share one rate-limit bucket")
|
|
}
|
|
}
|