mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(findings): record 2026-08-21 bug hunt (38 findings) * fix(api): 1 defect(s) (OC-0240) * fix(client): 1 defect(s) (OC-0241) * fix(plugin): 2 defect(s) (OC-0243, OC-0265) * fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259) * fix(client): 1 defect(s) (OC-0247) * fix(client): 2 defect(s) (OC-0248, OC-0258) * fix(identity): 1 defect(s) (OC-0250) * fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272) * fix(admin): 1 defect(s) (OC-0253) * fix(client): 1 defect(s) (OC-0254) * fix(voice): 1 defect(s) (OC-0255) * fix(ws): 1 defect(s) (OC-0260) * fix(client): 1 defect(s) (OC-0261) * fix(client): 1 defect(s) (OC-0262) * fix(client): 1 defect(s) (OC-0263) * fix(client): 1 defect(s) (OC-0264) * fix(client): 1 defect(s) (OC-0268) * fix(ws): 1 defect(s) (OC-0273) * fix(service): 1 defect(s) (OC-0275) * style: satisfy golangci-lint and prettier on 2026-08-21 fix commits - drop ineffectual backupDir reset before return (registry.go, OC-0265) - reflow long boolean expression (attachments.ts, OC-0241) * fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251) * fix(voice): 1 defect(s) (OC-0267) * fix(admin): 1 defect(s) (OC-0274) * fix(voice): 1 defect(s) (OC-0245) * fix(ws): 1 defect(s) (OC-0271) * fix(voice): 2 defect(s) (OC-0239, OC-0257) * fix(ws): 1 defect(s) (OC-0266) * fix(voice): 1 defect(s) (OC-0270) * style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes - range-over-int and slices.Contains modernizations in new Go test files - prettier reflow in dispatcher.ts * chore(findings): mark 2026-08-21 hunt findings fixed/declined 37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS requires a product decision, not a mechanical patch). --------- Co-authored-by: Claude <noreply@anthropic.com>
890 lines
32 KiB
Go
890 lines
32 KiB
Go
package db_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/owncord/server/db"
|
|
)
|
|
|
|
// ─── JoinVoiceChannelIfCapacity ─────────────────────────────────────────────
|
|
|
|
func TestVoice_JoinVoiceChannelIfCapacity_UnderLimit(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cap-u1")
|
|
chanID := seedVoiceChannel(t, database, "cap-ch")
|
|
|
|
err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2)
|
|
if err != nil {
|
|
t.Fatalf("JoinVoiceChannelIfCapacity: %v", err)
|
|
}
|
|
|
|
state, err := database.GetVoiceState(context.Background(), u1)
|
|
if err != nil {
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
}
|
|
if state == nil {
|
|
t.Fatal("expected voice state after join")
|
|
}
|
|
if state.ChannelID != chanID {
|
|
t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID)
|
|
}
|
|
}
|
|
|
|
func TestVoice_JoinVoiceChannelIfCapacity_AtLimit(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cap-full1")
|
|
u2 := seedVoiceUser(t, database, "cap-full2")
|
|
u3 := seedVoiceUser(t, database, "cap-full3")
|
|
chanID := seedVoiceChannel(t, database, "cap-full-ch")
|
|
|
|
// Fill channel to capacity (max 2).
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2); err != nil {
|
|
t.Fatalf("first join: %v", err)
|
|
}
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u2, chanID, 2); err != nil {
|
|
t.Fatalf("second join: %v", err)
|
|
}
|
|
|
|
// Third join should fail with ErrChannelFull.
|
|
err := database.JoinVoiceChannelIfCapacity(context.Background(), u3, chanID, 2)
|
|
if err == nil {
|
|
t.Fatal("expected ErrChannelFull, got nil")
|
|
}
|
|
if err != db.ErrChannelFull {
|
|
t.Errorf("error = %v, want ErrChannelFull", err)
|
|
}
|
|
}
|
|
|
|
func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cap-replace")
|
|
ch1 := seedVoiceChannel(t, database, "cap-ch1")
|
|
ch2 := seedVoiceChannel(t, database, "cap-ch2")
|
|
|
|
// Join ch1, then join ch2 with capacity check — should replace.
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch1, 5); err != nil {
|
|
t.Fatalf("join ch1: %v", err)
|
|
}
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch2, 5); err != nil {
|
|
t.Fatalf("join ch2: %v", err)
|
|
}
|
|
|
|
state, _ := database.GetVoiceState(context.Background(), u1)
|
|
if state == nil || state.ChannelID != ch2 {
|
|
t.Errorf("expected channel %d, got %v", ch2, state)
|
|
}
|
|
}
|
|
|
|
// OC-0255: a user who already holds the sole occupied-by-them row on the
|
|
// target channel must be able to re-issue the same join (e.g. retrying after
|
|
// a failed channel-switch leaves their old row in place) even when the
|
|
// channel is at capacity, because the upsert only replaces their own row --
|
|
// it does not add a new occupant. The capacity gate must exclude the
|
|
// requester's own existing row from the count, mirroring the OC-0081 fix
|
|
// already applied to EnableCameraIfUnderLimit / EnableScreenshareIfUnderLimit.
|
|
func TestVoice_JoinVoiceChannelIfCapacity_RejoinSameChannelAtLimit(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cap-rejoin-u1")
|
|
u2 := seedVoiceUser(t, database, "cap-rejoin-u2")
|
|
chanID := seedVoiceChannel(t, database, "cap-rejoin-ch")
|
|
|
|
// Fill channel to capacity (max 2).
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2); err != nil {
|
|
t.Fatalf("first join: %v", err)
|
|
}
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u2, chanID, 2); err != nil {
|
|
t.Fatalf("second join: %v", err)
|
|
}
|
|
|
|
// u1 re-issues a join for the SAME channel it already occupies. The
|
|
// channel is at capacity (2/2), but u1 is one of the two occupants, so
|
|
// this must succeed as a no-op replace of u1's own row, not be refused
|
|
// as ErrChannelFull.
|
|
if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2); err != nil {
|
|
t.Fatalf("rejoin of own channel at capacity should succeed, got: %v", err)
|
|
}
|
|
|
|
state, _ := database.GetVoiceState(context.Background(), u1)
|
|
if state == nil || state.ChannelID != chanID {
|
|
t.Errorf("expected u1 still in channel %d, got %v", chanID, state)
|
|
}
|
|
}
|
|
|
|
// ─── GetAllVoiceStates ──────────────────────────────────────────────────────
|
|
|
|
func TestVoice_GetAllVoiceStates_Empty(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
|
|
states, err := database.GetAllVoiceStates(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllVoiceStates: %v", err)
|
|
}
|
|
if len(states) != 0 {
|
|
t.Errorf("got %d states, want 0", len(states))
|
|
}
|
|
}
|
|
|
|
func TestVoice_GetAllVoiceStates_MultipleChannels(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "all-vs-u1")
|
|
u2 := seedVoiceUser(t, database, "all-vs-u2")
|
|
u3 := seedVoiceUser(t, database, "all-vs-u3")
|
|
ch1 := seedVoiceChannel(t, database, "all-vs-ch1")
|
|
ch2 := seedVoiceChannel(t, database, "all-vs-ch2")
|
|
|
|
_ = database.JoinVoiceChannel(context.Background(), u1, ch1)
|
|
_ = database.JoinVoiceChannel(context.Background(), u2, ch1)
|
|
_ = database.JoinVoiceChannel(context.Background(), u3, ch2)
|
|
|
|
states, err := database.GetAllVoiceStates(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllVoiceStates: %v", err)
|
|
}
|
|
if len(states) != 3 {
|
|
t.Errorf("got %d states, want 3", len(states))
|
|
}
|
|
}
|
|
|
|
// ─── CountActiveCameras ─────────────────────────────────────────────────────
|
|
|
|
func TestVoice_CountActiveCameras_Zero(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
chanID := seedVoiceChannel(t, database, "cam-count-empty")
|
|
|
|
count, err := database.CountActiveCameras(context.Background(), chanID)
|
|
if err != nil {
|
|
t.Fatalf("CountActiveCameras: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("count = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
func TestVoice_CountActiveCameras_SomeCameras(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cam-cnt-u1")
|
|
u2 := seedVoiceUser(t, database, "cam-cnt-u2")
|
|
u3 := seedVoiceUser(t, database, "cam-cnt-u3")
|
|
chanID := seedVoiceChannel(t, database, "cam-cnt-ch")
|
|
|
|
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
|
|
_ = database.JoinVoiceChannel(context.Background(), u2, chanID)
|
|
_ = database.JoinVoiceChannel(context.Background(), u3, chanID)
|
|
|
|
_ = database.UpdateVoiceCamera(context.Background(), u1, true)
|
|
_ = database.UpdateVoiceCamera(context.Background(), u2, true)
|
|
// u3 camera stays off.
|
|
|
|
count, err := database.CountActiveCameras(context.Background(), chanID)
|
|
if err != nil {
|
|
t.Fatalf("CountActiveCameras: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("count = %d, want 2", count)
|
|
}
|
|
}
|
|
|
|
// ─── EnableCameraIfUnderLimit ───────────────────────────────────────────────
|
|
|
|
func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cam-limit-ok")
|
|
chanID := seedVoiceChannel(t, database, "cam-limit-ch")
|
|
|
|
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
|
|
|
|
ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2)
|
|
if err != nil {
|
|
t.Fatalf("EnableCameraIfUnderLimit: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("expected camera to be enabled")
|
|
}
|
|
|
|
state, _ := database.GetVoiceState(context.Background(), u1)
|
|
if state == nil || !state.Camera {
|
|
t.Error("camera should be true after enable")
|
|
}
|
|
}
|
|
|
|
func TestVoice_EnableCameraIfUnderLimit_AtLimit(t *testing.T) {
|
|
database := newVoiceTestDB(t)
|
|
u1 := seedVoiceUser(t, database, "cam-lim-u1")
|
|
u2 := seedVoiceUser(t, database, "cam-lim-u2")
|
|
u3 := seedVoiceUser(t, database, "cam-lim-u3")
|
|
chanID := seedVoiceChannel(t, database, "cam-lim-ch")
|
|
|
|
_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
|
|
_ = database.JoinVoiceChannel(context.Background(), u2, chanID)
|
|
_ = database.JoinVoiceChannel(context.Background(), u3, chanID)
|
|
|
|
// Enable cameras for u1 and u2 (max is 2).
|
|
_, _ = database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2)
|
|
_, _ = database.EnableCameraIfUnderLimit(context.Background(), u2, chanID, 2)
|
|
|
|
// u3 should be denied.
|
|
ok, err := database.EnableCameraIfUnderLimit(context.Background(), u3, chanID, 2)
|
|
if err != nil {
|
|
t.Fatalf("EnableCameraIfUnderLimit: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected camera to be denied at limit")
|
|
}
|
|
}
|
|
|
|
// ─── SearchMessagesInChannels ───────────────────────────────────────────────
|
|
|
|
func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "srch-multi")
|
|
ch1 := seedChannel(t, database, "srch-ch1")
|
|
ch2 := seedChannel(t, database, "srch-ch2")
|
|
ch3 := seedChannel(t, database, "srch-ch3")
|
|
|
|
_, _ = database.CreateMessage(context.Background(), ch1, userID, "alpha keyword here", nil)
|
|
_, _ = database.CreateMessage(context.Background(), ch2, userID, "beta keyword here", nil)
|
|
_, _ = database.CreateMessage(context.Background(), ch3, userID, "gamma keyword here", nil)
|
|
|
|
// Search only in ch1 and ch2.
|
|
results, err := database.SearchMessagesInChannels(context.Background(), "keyword", []int64{ch1, ch2}, 10)
|
|
if err != nil {
|
|
t.Fatalf("SearchMessagesInChannels: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Errorf("expected 2 results, got %d", len(results))
|
|
}
|
|
for _, r := range results {
|
|
if r.ChannelID != ch1 && r.ChannelID != ch2 {
|
|
t.Errorf("unexpected channel_id %d in results", r.ChannelID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
results, err := database.SearchMessagesInChannels(context.Background(), "", []int64{1}, 10)
|
|
if err != nil {
|
|
t.Fatalf("SearchMessagesInChannels: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results for empty query, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchMessagesInChannels_EmptyChannelIDs(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
results, err := database.SearchMessagesInChannels(context.Background(), "test", nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("SearchMessagesInChannels: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results for no channels, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchMessagesInChannels_LimitRespected(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "srch-lim")
|
|
ch1 := seedChannel(t, database, "srch-lim-ch")
|
|
|
|
for range 5 {
|
|
_, _ = database.CreateMessage(context.Background(), ch1, userID, "findme content here", nil)
|
|
}
|
|
|
|
results, err := database.SearchMessagesInChannels(context.Background(), "findme", []int64{ch1}, 2)
|
|
if err != nil {
|
|
t.Fatalf("SearchMessagesInChannels: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Errorf("expected 2 results (limit), got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchMessagesInChannels_ZeroLimit(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
results, err := database.SearchMessagesInChannels(context.Background(), "test", []int64{1}, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results for zero limit, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
// ─── GetPinnedMessages ──────────────────────────────────────────────────────
|
|
|
|
func TestGetPinnedMessages_Empty(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "pin-empty-u")
|
|
chID := seedChannel(t, database, "pin-empty")
|
|
|
|
msgs, err := database.GetPinnedMessages(context.Background(), chID, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetPinnedMessages: %v", err)
|
|
}
|
|
if len(msgs) != 0 {
|
|
t.Errorf("expected 0 pinned messages, got %d", len(msgs))
|
|
}
|
|
}
|
|
|
|
func TestGetPinnedMessages_ReturnsPinnedOnly(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "pin-user")
|
|
chID := seedChannel(t, database, "pin-ch")
|
|
|
|
id1, _ := database.CreateMessage(context.Background(), chID, userID, "pinned msg", nil)
|
|
_, _ = database.CreateMessage(context.Background(), chID, userID, "not pinned", nil)
|
|
_ = database.SetMessagePinned(context.Background(), id1, true)
|
|
|
|
msgs, err := database.GetPinnedMessages(context.Background(), chID, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetPinnedMessages: %v", err)
|
|
}
|
|
if len(msgs) != 1 {
|
|
t.Fatalf("expected 1 pinned message, got %d", len(msgs))
|
|
}
|
|
if msgs[0].Content != "pinned msg" {
|
|
t.Errorf("Content = %q, want 'pinned msg'", msgs[0].Content)
|
|
}
|
|
if !msgs[0].Pinned {
|
|
t.Error("expected Pinned=true")
|
|
}
|
|
}
|
|
|
|
// ─── SetMessagePinned ───────────────────────────────────────────────────────
|
|
|
|
func TestSetMessagePinned_Pin(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "setpin-u")
|
|
chID := seedChannel(t, database, "setpin-ch")
|
|
|
|
id, _ := database.CreateMessage(context.Background(), chID, userID, "to pin", nil)
|
|
|
|
if err := database.SetMessagePinned(context.Background(), id, true); err != nil {
|
|
t.Fatalf("SetMessagePinned(true): %v", err)
|
|
}
|
|
|
|
msg, _ := database.GetMessage(context.Background(), id)
|
|
if msg == nil || !msg.Pinned {
|
|
t.Error("message should be pinned")
|
|
}
|
|
}
|
|
|
|
func TestSetMessagePinned_Unpin(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "unpin-u")
|
|
chID := seedChannel(t, database, "unpin-ch")
|
|
|
|
id, _ := database.CreateMessage(context.Background(), chID, userID, "to unpin", nil)
|
|
_ = database.SetMessagePinned(context.Background(), id, true)
|
|
if err := database.SetMessagePinned(context.Background(), id, false); err != nil {
|
|
t.Fatalf("SetMessagePinned(false): %v", err)
|
|
}
|
|
|
|
msg, _ := database.GetMessage(context.Background(), id)
|
|
if msg == nil || msg.Pinned {
|
|
t.Error("message should not be pinned")
|
|
}
|
|
}
|
|
|
|
func TestSetMessagePinned_NotFound(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
err := database.SetMessagePinned(context.Background(), 99999, true)
|
|
if err == nil {
|
|
t.Error("expected error for non-existent message")
|
|
}
|
|
}
|
|
|
|
func TestSetMessagePinned_DeletedMessage(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "pin-del-u")
|
|
chID := seedChannel(t, database, "pin-del-ch")
|
|
|
|
id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted", nil)
|
|
_ = database.DeleteMessage(context.Background(), id, userID, false)
|
|
|
|
err := database.SetMessagePinned(context.Background(), id, true)
|
|
if err == nil {
|
|
t.Error("expected error when pinning deleted message")
|
|
}
|
|
}
|
|
|
|
// ─── CreateAttachment ───────────────────────────────────────────────────────
|
|
|
|
func TestCreateAttachment_Success(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "att-uploader")
|
|
|
|
err := database.CreateAttachment(context.Background(), "att-001", userID, "photo.png", "stored-001.png", "image/png", 12345, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateAttachment: %v", err)
|
|
}
|
|
|
|
att, err := database.GetAttachmentByID(context.Background(), "att-001")
|
|
if err != nil {
|
|
t.Fatalf("GetAttachmentByID: %v", err)
|
|
}
|
|
if att == nil {
|
|
t.Fatal("expected attachment, got nil")
|
|
}
|
|
if att.Filename != "photo.png" {
|
|
t.Errorf("Filename = %q, want 'photo.png'", att.Filename)
|
|
}
|
|
if att.Size != 12345 {
|
|
t.Errorf("Size = %d, want 12345", att.Size)
|
|
}
|
|
if att.MimeType != "image/png" {
|
|
t.Errorf("MimeType = %q, want 'image/png'", att.MimeType)
|
|
}
|
|
}
|
|
|
|
func TestCreateAttachment_WithDimensions(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "att-dim-uploader")
|
|
chID := seedChannel(t, database, "att-dim-chan")
|
|
msgID, err := database.CreateMessage(context.Background(), chID, userID, "with dims", nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateMessage: %v", err)
|
|
}
|
|
|
|
w, h := 1920, 1080
|
|
err = database.CreateAttachment(context.Background(), "att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h)
|
|
if err != nil {
|
|
t.Fatalf("CreateAttachment with dims: %v", err)
|
|
}
|
|
|
|
att, _ := database.GetAttachmentByID(context.Background(), "att-dim")
|
|
if att == nil {
|
|
t.Fatal("expected attachment")
|
|
}
|
|
|
|
if n, linkErr := database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"att-dim"}); linkErr != nil || n != 1 {
|
|
t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, linkErr)
|
|
}
|
|
|
|
byMsg, err := database.GetAttachmentsByMessageIDs(context.Background(), []int64{msgID})
|
|
if err != nil {
|
|
t.Fatalf("GetAttachmentsByMessageIDs: %v", err)
|
|
}
|
|
infos := byMsg[msgID]
|
|
if len(infos) != 1 {
|
|
t.Fatalf("expected 1 attachment for message, got %d", len(infos))
|
|
}
|
|
if infos[0].Width == nil || *infos[0].Width != w {
|
|
t.Errorf("Width = %v, want %d", infos[0].Width, w)
|
|
}
|
|
if infos[0].Height == nil || *infos[0].Height != h {
|
|
t.Errorf("Height = %v, want %d", infos[0].Height, h)
|
|
}
|
|
}
|
|
|
|
// ─── DeleteOrphanedAttachments ──────────────────────────────────────────────
|
|
|
|
func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "orphan-uploader")
|
|
|
|
// Create an unlinked attachment (message_id IS NULL).
|
|
_ = database.CreateAttachment(context.Background(), "orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil)
|
|
|
|
// Use a cutoff far in the future so the attachment is considered old.
|
|
files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("expected 1 orphan, got %d", len(files))
|
|
}
|
|
if files[0] != "stored-orphan.txt" {
|
|
t.Errorf("stored_as = %q, want 'stored-orphan.txt'", files[0])
|
|
}
|
|
|
|
// Should be removed from DB.
|
|
att, _ := database.GetAttachmentByID(context.Background(), "orphan-1")
|
|
if att != nil {
|
|
t.Error("orphaned attachment should be deleted from DB")
|
|
}
|
|
}
|
|
|
|
func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "orphan-linked-u")
|
|
chID := seedChannel(t, database, "orphan-linked-ch")
|
|
|
|
// Create attachment and link it to a message.
|
|
_ = database.CreateAttachment(context.Background(), "linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil)
|
|
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil)
|
|
_, _ = database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"linked-1"})
|
|
|
|
files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
|
}
|
|
if len(files) != 0 {
|
|
t.Errorf("expected 0 orphans (linked), got %d", len(files))
|
|
}
|
|
}
|
|
|
|
// An avatar is an attachment that is deliberately never linked to a message:
|
|
// users.avatar points at it by URL and that reference is what authorizes serving
|
|
// it (migration 027). The orphan sweep must therefore not treat it as garbage.
|
|
func TestDeleteOrphanedAttachments_KeepsLiveAvatars(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "avatar-owner")
|
|
|
|
_ = database.CreateAttachment(context.Background(), "avatar-1", userID, "me.png", "stored-avatar.png", "image/png", 100, nil, nil)
|
|
avatarURL := "/api/v1/files/avatar-1"
|
|
if err := database.UpdateUserProfile(context.Background(), userID, "avatar-owner", &avatarURL, nil, nil); err != nil {
|
|
t.Fatalf("UpdateUserProfile: %v", err)
|
|
}
|
|
|
|
files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
|
}
|
|
if len(files) != 0 {
|
|
t.Errorf("expected 0 deletions, got %d (%v) — a live avatar was swept", len(files), files)
|
|
}
|
|
att, _ := database.GetAttachmentByID(context.Background(), "avatar-1")
|
|
if att == nil {
|
|
t.Error("live avatar attachment must survive the orphan sweep")
|
|
}
|
|
}
|
|
|
|
// The sweep runs with a one-hour grace period. uploaded_at is written by SQLite
|
|
// as 'YYYY-MM-DD HH:MM:SS', so a cutoff in any other shape compares bytewise
|
|
// against it and silently collapses the grace window.
|
|
func TestDeleteOrphanedAttachments_GracePeriodHoldsSameDay(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "grace-uploader")
|
|
|
|
_ = database.CreateAttachment(context.Background(), "fresh-1", userID, "file.txt", "stored-fresh.txt", "text/plain", 100, nil, nil)
|
|
|
|
// Exactly what the maintenance loop passes: one hour ago.
|
|
files, err := database.DeleteOrphanedAttachments(context.Background(), time.Now().Add(-1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
|
}
|
|
if len(files) != 0 {
|
|
t.Errorf("expected 0 deletions, got %d (%v) — an upload inside the grace period was swept", len(files), files)
|
|
}
|
|
}
|
|
|
|
func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "cutoff-uploader")
|
|
|
|
_ = database.CreateAttachment(context.Background(), "future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil)
|
|
|
|
// Cutoff in the past — newly created attachment should NOT be deleted.
|
|
files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("DeleteOrphanedAttachments: %v", err)
|
|
}
|
|
if len(files) != 0 {
|
|
t.Errorf("expected 0 orphans (cutoff too old), got %d", len(files))
|
|
}
|
|
}
|
|
|
|
// ─── GetAllChannelPermissionsForRole ────────────────────────────────────────
|
|
|
|
func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4)
|
|
if err != nil {
|
|
t.Fatalf("GetAllChannelPermissionsForRole: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty map, got %d entries", len(result))
|
|
}
|
|
}
|
|
|
|
func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
ch1, _ := database.CreateChannel(context.Background(), "perm-ch1", "text", "", "", 0)
|
|
ch2, _ := database.CreateChannel(context.Background(), "perm-ch2", "text", "", "", 0)
|
|
|
|
// Insert overrides for role 4.
|
|
_, _ = database.ExecContext(context.Background(),
|
|
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
|
|
ch1, 4, int64(0x100), int64(0x200),
|
|
)
|
|
_, _ = database.ExecContext(context.Background(),
|
|
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
|
|
ch2, 4, int64(0x300), int64(0),
|
|
)
|
|
|
|
result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4)
|
|
if err != nil {
|
|
t.Fatalf("GetAllChannelPermissionsForRole: %v", err)
|
|
}
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(result))
|
|
}
|
|
if o, ok := result[ch1]; !ok || o.Allow != 0x100 || o.Deny != 0x200 {
|
|
t.Errorf("ch1 override = %+v, want allow=0x100 deny=0x200", result[ch1])
|
|
}
|
|
}
|
|
|
|
// ─── GetChannelTypes ────────────────────────────────────────────────────────
|
|
|
|
func TestGetChannelTypes_Empty(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
result, err := database.GetChannelTypes(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("GetChannelTypes: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty map, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
func TestGetChannelTypes_ReturnsTypes(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
ch1, _ := database.CreateChannel(context.Background(), "type-text", "text", "", "", 0)
|
|
ch2, _ := database.CreateChannel(context.Background(), "type-voice", "voice", "", "", 0)
|
|
|
|
result, err := database.GetChannelTypes(context.Background(), []int64{ch1, ch2})
|
|
if err != nil {
|
|
t.Fatalf("GetChannelTypes: %v", err)
|
|
}
|
|
if result[ch1] != "text" {
|
|
t.Errorf("ch1 type = %q, want 'text'", result[ch1])
|
|
}
|
|
if result[ch2] != "voice" {
|
|
t.Errorf("ch2 type = %q, want 'voice'", result[ch2])
|
|
}
|
|
}
|
|
|
|
func TestGetChannelTypes_NonExistentIDs(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
result, err := database.GetChannelTypes(context.Background(), []int64{99999})
|
|
if err != nil {
|
|
t.Fatalf("GetChannelTypes: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty map for non-existent IDs, got %d", len(result))
|
|
}
|
|
}
|
|
|
|
// ─── CountUsersWithoutTOTP ──────────────────────────────────────────────────
|
|
|
|
func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
_, _ = database.CreateUser(context.Background(), "totp-u1", "hash", 4)
|
|
_, _ = database.CreateUser(context.Background(), "totp-u2", "hash", 4)
|
|
|
|
count, err := database.CountUsersWithoutTOTP(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CountUsersWithoutTOTP: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("count = %d, want 2", count)
|
|
}
|
|
}
|
|
|
|
func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
uid, _ := database.CreateUser(context.Background(), "totp-with", "hash", 4)
|
|
_, _ = database.CreateUser(context.Background(), "totp-without", "hash", 4)
|
|
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
_ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret)
|
|
|
|
count, err := database.CountUsersWithoutTOTP(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CountUsersWithoutTOTP: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("count = %d, want 1 (one has TOTP)", count)
|
|
}
|
|
}
|
|
|
|
// ─── UpdateUserTOTPSecret ───────────────────────────────────────────────────
|
|
|
|
func TestUpdateUserTOTPSecret_Set(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
uid, _ := database.CreateUser(context.Background(), "totp-set", "hash", 4)
|
|
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
if err := database.UpdateUserTOTPSecret(context.Background(), uid, &secret); err != nil {
|
|
t.Fatalf("UpdateUserTOTPSecret(set): %v", err)
|
|
}
|
|
|
|
user, _ := database.GetUserByID(context.Background(), uid)
|
|
if user == nil || user.TOTPSecret == nil || *user.TOTPSecret != secret {
|
|
t.Error("TOTP secret should be set")
|
|
}
|
|
}
|
|
|
|
func TestUpdateUserTOTPSecret_Clear(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
uid, _ := database.CreateUser(context.Background(), "totp-clear", "hash", 4)
|
|
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
_ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret)
|
|
if err := database.UpdateUserTOTPSecret(context.Background(), uid, nil); err != nil {
|
|
t.Fatalf("UpdateUserTOTPSecret(clear): %v", err)
|
|
}
|
|
|
|
user, _ := database.GetUserByID(context.Background(), uid)
|
|
if user == nil || user.TOTPSecret != nil {
|
|
t.Error("TOTP secret should be nil after clear")
|
|
}
|
|
}
|
|
|
|
// ─── CreateUserWithInvite ───────────────────────────────────────────────────
|
|
|
|
func TestCreateUserWithInvite_Success(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
// Create a user who will create the invite.
|
|
creatorID, _ := database.CreateUser(context.Background(), "invite-creator", "hash", 2)
|
|
|
|
code, err := database.CreateInvite(context.Background(), creatorID, 5, nil)
|
|
if err != nil {
|
|
t.Fatalf("CreateInvite: %v", err)
|
|
}
|
|
|
|
uid, err := database.CreateUserWithInvite(context.Background(), "newuser", "hash", 4, code)
|
|
if err != nil {
|
|
t.Fatalf("CreateUserWithInvite: %v", err)
|
|
}
|
|
if uid <= 0 {
|
|
t.Errorf("expected positive user ID, got %d", uid)
|
|
}
|
|
|
|
// Verify invite use count incremented.
|
|
inv, _ := database.GetInvite(context.Background(), code)
|
|
if inv == nil || inv.Uses != 1 {
|
|
t.Errorf("invite uses = %v, want 1", inv)
|
|
}
|
|
}
|
|
|
|
func TestCreateUserWithInvite_InvalidCode(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
_, err := database.CreateUserWithInvite(context.Background(), "baduser", "hash", 4, "nonexistent-code")
|
|
if err == nil {
|
|
t.Error("expected error for invalid invite code")
|
|
}
|
|
}
|
|
|
|
func TestCreateUserWithInvite_RevokedInvite(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
creatorID, _ := database.CreateUser(context.Background(), "inv-revoke-creator", "hash", 2)
|
|
|
|
code, _ := database.CreateInvite(context.Background(), creatorID, 0, nil)
|
|
_ = database.RevokeInvite(context.Background(), code)
|
|
|
|
_, err := database.CreateUserWithInvite(context.Background(), "revokeduser", "hash", 4, code)
|
|
if err == nil {
|
|
t.Error("expected error for revoked invite")
|
|
}
|
|
}
|
|
|
|
func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
creatorID, _ := database.CreateUser(context.Background(), "inv-expire-creator", "hash", 2)
|
|
|
|
// Create an invite that expires in the past.
|
|
pastTime := time.Now().Add(-1 * time.Hour)
|
|
code, _ := database.CreateInvite(context.Background(), creatorID, 0, &pastTime)
|
|
|
|
_, err := database.CreateUserWithInvite(context.Background(), "expireduser", "hash", 4, code)
|
|
if err == nil {
|
|
t.Error("expected error for expired invite")
|
|
}
|
|
}
|
|
|
|
// ─── ListInvites (db layer) ─────────────────────────────────────────────────
|
|
|
|
func TestListInvites_DB_Empty(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
invites, err := database.ListInvites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListInvites: %v", err)
|
|
}
|
|
if len(invites) != 0 {
|
|
t.Errorf("expected 0 invites, got %d", len(invites))
|
|
}
|
|
}
|
|
|
|
func TestListInvites_DB_ReturnsAll(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
creatorID, _ := database.CreateUser(context.Background(), "list-inv-creator", "hash", 2)
|
|
|
|
_, _ = database.CreateInvite(context.Background(), creatorID, 5, nil)
|
|
_, _ = database.CreateInvite(context.Background(), creatorID, 0, nil)
|
|
|
|
invites, err := database.ListInvites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListInvites: %v", err)
|
|
}
|
|
if len(invites) != 2 {
|
|
t.Errorf("expected 2 invites, got %d", len(invites))
|
|
}
|
|
}
|
|
|
|
func TestUseInviteAtomic_NonExistent(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
err := database.UseInviteAtomic(context.Background(), "does-not-exist")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent invite")
|
|
}
|
|
}
|
|
|
|
// ─── SearchMessages edge cases ──────────────────────────────────────────────
|
|
|
|
func TestSearchMessages_EmptyQuery(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
results, err := database.SearchMessages(context.Background(), "", nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results for empty query, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchMessages_ZeroLimit(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
|
|
results, err := database.SearchMessages(context.Background(), "test", nil, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results for zero limit, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestSearchMessages_SpecialCharsStripped(t *testing.T) {
|
|
database := openMigratedMemory(t)
|
|
userID := seedUser(t, database, "srch-special")
|
|
chID := seedChannel(t, database, "srch-special-ch")
|
|
|
|
_, _ = database.CreateMessage(context.Background(), chID, userID, "hello world content", nil)
|
|
|
|
// FTS special chars should be stripped, leaving a valid query.
|
|
results, err := database.SearchMessages(context.Background(), "hello* \"world\"", nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("SearchMessages with special chars: %v", err)
|
|
}
|
|
// Should not crash, results may vary.
|
|
_ = results
|
|
}
|