Files
OwnCord/Server/db/attachment_queries_test.go
T
J3vbandClaude Fable 5 6afa9e974c refactor(server): thread context.Context through the db layer and all callers
Fixes all 109 golangci-lint findings (106 contextcheck, 1 gocritic,
2 gosec) that accumulated after D2 wired dbgen (whose queries take ctx)
under ctx-less db.DB wrappers while CI lint was quota-dead. No nolint
comments added; every finding fixed by genuinely threading context.

- db: all 138 hand-written db.DB methods take ctx first; the dbCtx()
  Background shim is deleted; raw Query/QueryRow/Exec/Begin use their
  Context variants; the four redundant ctx-less passthroughs removed.
  db.Auditor/WriteAudit gain ctx.
- Seams: permissions.Checker (DB iface, HasChannelPerm,
  RequireChannelAccess) and the service.Store interface mirror the new
  signatures (ws.EventStore and plugin.PluginStore already did).
- Callers: api/admin handlers use r.Context(); ws per-message paths use
  the connection ctx via DispatchV2; hub loops and startup wiring use
  context.Background(); service methods thread ctx where they have one
  and Background where no ctx exists. Public service surface reached by
  ctx-holding chains (PermissionService.HasChannelPerm/GetRoleForUser/
  RequireChannelAccess, message/dm/block/invite/profile methods) is now
  ctx-first.
- Detached (context.WithoutCancel) where cancellation would break an
  invariant, found by a 3-lens adversarial review of the diff:
  * voice-leave background retries (a dead webhook/connection ctx killed
    retry 2 before it ran, leaving ghost capacity-holding voice rows)
  * rollbackVoiceJoin's compensating delete (its trigger IS the cancel)
  * post-2FA-change DeleteOtherSessions and logout DeleteSession (the
    security tail of a committed change must not die with the request)
  * all api/ws audit writes (a banned user could suppress their own
    login_blocked_banned row by aborting the request mid-bcrypt)
  * admin backup VACUUM INTO (an interrupt left a truncated .db that
    the backup list presented as restorable)
  * post-commit message/edit refetches (a committed message must still
    fan out when the sender disconnects)
  * hub settings-cache refresh (one dead connection could pin stale
    values for the 30s TTL)
- gocritic rangeValCopy fixed (index iteration); gosec G306 excluded in
  config with justification (generated source must stay world-readable)
  instead of flipping genprotocol output to 0o600.

Verified: gofmt/vet, all four build-tag variants, full suite, deadlock
pass, full -race pass, golangci-lint 0 issues uncapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:03:52 +02:00

227 lines
8.0 KiB
Go

package db_test
import (
"context"
"testing"
)
// ─── GetAttachmentByID ──────────────────────────────────────────────────────
func TestGetAttachmentByID_NotFound(t *testing.T) {
database := openMigratedMemory(t)
att, err := database.GetAttachmentByID(context.Background(), "nonexistent-id")
if err != nil {
t.Errorf("GetAttachmentByID for nonexistent ID should return nil error, got %v", err)
}
if att != nil {
t.Error("GetAttachmentByID for nonexistent ID should return nil attachment")
}
}
func TestGetAttachmentByID_Found(t *testing.T) {
database := openMigratedMemory(t)
// Insert an attachment directly.
_, err := database.ExecContext(context.Background(),
`INSERT INTO attachments (id, filename, stored_as, mime_type, size)
VALUES (?, ?, ?, ?, ?)`,
"att-001", "photo.png", "stored-photo.png", "image/png", 12345,
)
if err != nil {
t.Fatalf("inserting attachment: %v", err)
}
att, err := database.GetAttachmentByID(context.Background(), "att-001")
if err != nil {
t.Fatalf("GetAttachmentByID: %v", err)
}
if att.ID != "att-001" {
t.Errorf("ID = %q, want 'att-001'", att.ID)
}
if att.Filename != "photo.png" {
t.Errorf("Filename = %q, want 'photo.png'", att.Filename)
}
if att.MimeType != "image/png" {
t.Errorf("MimeType = %q, want 'image/png'", att.MimeType)
}
if att.Size != 12345 {
t.Errorf("Size = %d, want 12345", att.Size)
}
if att.MessageID != nil {
t.Errorf("MessageID = %v, want nil (unlinked)", att.MessageID)
}
}
// ─── LinkAttachmentsToMessage ────────────────────────────────────────────────
func TestLinkAttachmentsToMessage_Empty(t *testing.T) {
database := openMigratedMemory(t)
n, err := database.LinkAttachmentsToMessage(context.Background(), 1, 1, nil)
if err != nil {
t.Fatalf("LinkAttachmentsToMessage(nil): %v", err)
}
if n != 0 {
t.Errorf("expected 0 rows affected, got %d", n)
}
}
func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "linkuser")
chID := seedChannel(t, database, "linkchan")
msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil)
// Insert two unlinked attachments.
for _, id := range []string{"att-a", "att-b"} {
_, err := database.ExecContext(context.Background(),
`INSERT INTO attachments (id, filename, stored_as, mime_type, size)
VALUES (?, ?, ?, ?, ?)`,
id, "file.txt", "stored.txt", "text/plain", 100,
)
if err != nil {
t.Fatalf("inserting attachment %s: %v", id, err)
}
}
n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"att-a", "att-b"})
if err != nil {
t.Fatalf("LinkAttachmentsToMessage: %v", err)
}
if n != 2 {
t.Errorf("expected 2 rows affected, got %d", n)
}
// Verify linkage.
att, _ := database.GetAttachmentByID(context.Background(), "att-a")
if att.MessageID == nil || *att.MessageID != msgID {
t.Errorf("att-a MessageID = %v, want %d", att.MessageID, msgID)
}
}
func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "linkuser2")
chID := seedChannel(t, database, "linkchan2")
msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil)
msg2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil)
_, _ = database.ExecContext(context.Background(),
`INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id)
VALUES (?, ?, ?, ?, ?, ?)`,
"att-linked", "file.txt", "stored.txt", "text/plain", 100, msg1,
)
// Try to re-link to a different message — should skip (WHERE message_id IS NULL).
n, err := database.LinkAttachmentsToMessage(context.Background(), msg2, userID, []string{"att-linked"})
if err != nil {
t.Fatalf("LinkAttachmentsToMessage: %v", err)
}
if n != 0 {
t.Errorf("expected 0 rows (already linked), got %d", n)
}
}
// TestLinkAttachmentsToMessage_OwnershipGuard locks the atomic IDOR guard
// (W1-3): the link UPDATE itself enforces ownership, so a foreign attachment
// can never be claimed, legacy NULL-uploader rows remain claimable, and
// nonexistent ids are skipped without failing the statement.
func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) {
database := openMigratedMemory(t)
owner := seedUser(t, database, "att-owner")
other := seedUser(t, database, "att-other")
chID := seedChannel(t, database, "att-owner-ch")
msgID, _ := database.CreateMessage(context.Background(), chID, owner, "attachment carrier", nil)
if err := database.CreateAttachment(context.Background(), "att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil {
t.Fatalf("CreateAttachment att-owned: %v", err)
}
if err := database.CreateAttachment(context.Background(), "att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil {
t.Fatalf("CreateAttachment att-foreign: %v", err)
}
// Legacy row from before uploader tracking: uploader_id IS NULL.
if _, err := database.ExecContext(context.Background(),
`INSERT INTO attachments (id, filename, stored_as, mime_type, size)
VALUES ('att-legacy', 'l.txt', 's-l.txt', 'text/plain', 1)`,
); err != nil {
t.Fatalf("inserting legacy attachment: %v", err)
}
n, err := database.LinkAttachmentsToMessage(context.Background(), msgID, owner,
[]string{"att-owned", "att-foreign", "att-legacy", "att-missing"})
if err != nil {
t.Fatalf("LinkAttachmentsToMessage: %v", err)
}
if n != 2 {
t.Errorf("expected 2 linked (owned + legacy), got %d", n)
}
if att, _ := database.GetAttachmentByID(context.Background(), "att-owned"); att.MessageID == nil || *att.MessageID != msgID {
t.Error("owner's unlinked attachment should link")
}
if att, _ := database.GetAttachmentByID(context.Background(), "att-foreign"); att.MessageID != nil {
t.Error("another user's attachment must never link (IDOR guard)")
}
if att, _ := database.GetAttachmentByID(context.Background(), "att-legacy"); att.MessageID == nil {
t.Error("legacy NULL-uploader attachment should be claimable")
}
}
// ─── GetAttachmentsByMessageIDs ──────────────────────────────────────────────
func TestGetAttachmentsByMessageIDs_Empty(t *testing.T) {
database := openMigratedMemory(t)
result, err := database.GetAttachmentsByMessageIDs(context.Background(), nil)
if err != nil {
t.Fatalf("GetAttachmentsByMessageIDs(nil): %v", err)
}
if len(result) != 0 {
t.Errorf("expected empty map, got %d entries", len(result))
}
}
func TestGetAttachmentsByMessageIDs_GroupsByMessage(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "attuser")
chID := seedChannel(t, database, "attchan")
msg1, _ := database.CreateMessage(context.Background(), chID, userID, "msg1", nil)
msg2, _ := database.CreateMessage(context.Background(), chID, userID, "msg2", nil)
// Two attachments on msg1, one on msg2.
for _, row := range []struct {
id string
msgID int64
}{
{"att-1a", msg1},
{"att-1b", msg1},
{"att-2a", msg2},
} {
_, err := database.ExecContext(context.Background(),
`INSERT INTO attachments (id, filename, stored_as, mime_type, size, message_id)
VALUES (?, ?, ?, ?, ?, ?)`,
row.id, "f.txt", "s.txt", "text/plain", 50, row.msgID,
)
if err != nil {
t.Fatalf("insert %s: %v", row.id, err)
}
}
result, err := database.GetAttachmentsByMessageIDs(context.Background(), []int64{msg1, msg2})
if err != nil {
t.Fatalf("GetAttachmentsByMessageIDs: %v", err)
}
if len(result[msg1]) != 2 {
t.Errorf("msg1 attachments = %d, want 2", len(result[msg1]))
}
if len(result[msg2]) != 1 {
t.Errorf("msg2 attachments = %d, want 1", len(result[msg2]))
}
// Verify URL format.
for _, ai := range result[msg1] {
if ai.URL == "" {
t.Error("attachment URL should not be empty")
}
}
}