package retention import ( "context" "os" "path/filepath" "testing" "time" "glchat/internal/database" "glchat/internal/store" ) // Тесты чистки (AGENT.md 6.4): истёкшие сессии, старый аудит и файлы без // ссылок удаляются, а используемые файлы остаются. func newRetentionStore(t *testing.T) (*store.Store, *database.DB, string) { t.Helper() dir := t.TempDir() db, err := database.Open(context.Background(), database.Options{ Path: filepath.Join(dir, "glchat.db"), ReadPool: 1, Migrate: true, }) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = db.Close() }) return store.New(db), db, dir } func TestCleanupRemovesOrphansAndKeepsReferences(t *testing.T) { st, db, dataDir := newRetentionStore(t) ctx := context.Background() filesDir := filepath.Join(dataDir, "files") if err := os.MkdirAll(filesDir, 0o700); err != nil { t.Fatalf("prepare files dir: %v", err) } writeFile := func(name string) string { path := filepath.Join(filesDir, name) if err := os.WriteFile(path, []byte("данные"), 0o600); err != nil { t.Fatalf("write file: %v", err) } return path } user, err := st.CreateUser(ctx, store.CreateUserParams{ Username: "cleanup", EmailEnc: "enc", EmailIndex: "cleanup@example.com", PasswordHash: "x", Locale: "ru", }) if err != nil { t.Fatalf("create user: %v", err) } // Аватар пользователя: на файл есть ссылка, он должен остаться. avatarPath := writeFile("avatar.bin") avatar, err := st.CreateFile(ctx, store.CreateFileParams{ UploaderID: user.ID, Filename: "avatar.png", ContentType: "image/png", SizeBytes: 6, StoragePath: avatarPath, SHA256: "a", Purpose: "avatar", }) if err != nil { t.Fatalf("create avatar file: %v", err) } if _, err := st.UpdateUser(ctx, user.ID, store.UpdateUserParams{AvatarFileID: &avatar.ID}); err != nil { t.Fatalf("attach avatar: %v", err) } // Вложение сообщения: ссылка в attachments_json. channelPath := writeFile("attachment.bin") attachment, err := st.CreateFile(ctx, store.CreateFileParams{ UploaderID: user.ID, Filename: "note.txt", ContentType: "text/plain", SizeBytes: 6, StoragePath: channelPath, SHA256: "b", }) if err != nil { t.Fatalf("create attachment: %v", err) } guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Чистка", OwnerID: user.ID}) if err != nil { t.Fatalf("create guild: %v", err) } channel, err := st.CreateChannel(ctx, store.CreateChannelParams{ GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", }) if err != nil { t.Fatalf("create channel: %v", err) } message, err := st.CreateMessage(ctx, store.CreateMessageParams{ ChannelID: channel.ID, AuthorID: user.ID, Content: "файл", Attachments: []store.Attachment{{FileID: attachment.ID, Filename: "note.txt"}}, }) if err != nil { t.Fatalf("create message: %v", err) } if err := st.AttachFileToMessage(ctx, attachment.ID, message.ID); err != nil { t.Fatalf("attach file: %v", err) } // Сирота: файл без ссылок. orphanPath := writeFile("orphan.bin") orphan, err := st.CreateFile(ctx, store.CreateFileParams{ UploaderID: user.ID, Filename: "orphan.bin", ContentType: "application/octet-stream", SizeBytes: 6, StoragePath: orphanPath, SHA256: "c", }) if err != nil { t.Fatalf("create orphan: %v", err) } // Сессии: одна истёкшая, одна действующая. expired, err := st.CreateSession(ctx, user.ID, store.CreateSessionParams{ TokenHash: "expired", UserAgent: "test", IP: "127.0.0.1", TTL: time.Hour, }) if err != nil { t.Fatalf("create expired session: %v", err) } // Просрочиваем сессию напрямую: TTL меньше часа не принимается хранилищем. if _, err := db.Writer.ExecContext(ctx, `UPDATE sessions SET expires_at = ? WHERE token_hash = 'expired'`, "2020-01-01T00:00:00.000Z"); err != nil { t.Fatalf("expire session: %v", err) } active, err := st.CreateSession(ctx, user.ID, store.CreateSessionParams{ TokenHash: "active", UserAgent: "test", IP: "127.0.0.1", TTL: time.Hour, }) if err != nil { t.Fatalf("create active session: %v", err) } // Запись аудита: старую чистим. if err := st.RecordAudit(ctx, store.AuditParams{ GuildID: &guild.ID, ActorID: &user.ID, Action: "test.old", }); err != nil { t.Fatalf("record audit: %v", err) } // Сироту состариваем: свежие файлы не удаляются (OrphanHours > 0). if _, err := db.Writer.ExecContext(ctx, `UPDATE files SET created_at = '2020-01-01T00:00:00.000Z' WHERE id = ?`, int64(orphan.ID)); err != nil { t.Fatalf("age orphan file: %v", err) } runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24}) if _, err := runner.RunOnce(ctx); err != nil { t.Fatalf("cleanup: %v", err) } // Истёкшая сессия удалена, действующая осталась. if _, err := st.GetSessionByTokenHash(ctx, "expired"); err == nil { t.Fatal("истёкшая сессия не удалена") } if _, err := st.GetSessionByTokenHash(ctx, "active"); err != nil { t.Fatalf("действующая сессия удалена: %v", err) } _ = expired _ = active // Файл-сирота удалён с диска и из базы, используемые файлы на месте. if _, err := os.Stat(orphanPath); !os.IsNotExist(err) { t.Fatalf("файл-сирота остался на диске: %v", err) } if _, err := st.GetFile(ctx, orphan.ID); err == nil { t.Fatal("запись о сироте осталась в базе") } for _, kept := range []uint64{avatar.ID, attachment.ID} { if _, err := st.GetFile(ctx, kept); err != nil { t.Fatalf("используемый файл %d удалён: %v", kept, err) } } if _, err := os.Stat(avatarPath); err != nil { t.Fatalf("аватар удалён с диска: %v", err) } if _, err := os.Stat(channelPath); err != nil { t.Fatalf("вложение удалено с диска: %v", err) } } func TestCleanupKeepsFreshOrphansAndRecentAudit(t *testing.T) { st, _, dataDir := newRetentionStore(t) ctx := context.Background() filesDir := filepath.Join(dataDir, "files") if err := os.MkdirAll(filesDir, 0o700); err != nil { t.Fatalf("prepare files dir: %v", err) } path := filepath.Join(filesDir, "fresh.bin") if err := os.WriteFile(path, []byte("данные"), 0o600); err != nil { t.Fatalf("write file: %v", err) } user, err := st.CreateUser(ctx, store.CreateUserParams{ Username: "fresh", EmailEnc: "enc", EmailIndex: "fresh@example.com", PasswordHash: "x", Locale: "ru", }) if err != nil { t.Fatalf("create user: %v", err) } if _, err := st.CreateFile(ctx, store.CreateFileParams{ UploaderID: user.ID, Filename: "fresh.bin", ContentType: "text/plain", SizeBytes: 6, StoragePath: path, SHA256: "d", }); err != nil { t.Fatalf("create file: %v", err) } // Свежий файл (OrphanHours = 24) не трогаем. runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24}) stats, err := runner.RunOnce(ctx) if err != nil { t.Fatalf("cleanup: %v", err) } if stats.Files != 0 { t.Fatalf("удалено файлов: %d, ожидалось 0", stats.Files) } if _, err := os.Stat(path); err != nil { t.Fatalf("свежий файл удалён: %v", err) } } func TestCleanupRemovesFilesWithoutRecords(t *testing.T) { st, _, dataDir := newRetentionStore(t) ctx := context.Background() filesDir := filepath.Join(dataDir, "files") nested := filepath.Join(filesDir, "ab") if err := os.MkdirAll(nested, 0o700); err != nil { t.Fatalf("prepare files dir: %v", err) } // Файл на диске, записи о котором нет: так остаются вложения удалённых // серверов (запись уходит каскадом, содержимое — нет). orphan := filepath.Join(nested, "orphan.bin") if err := os.WriteFile(orphan, []byte("мусор"), 0o600); err != nil { t.Fatalf("write orphan: %v", err) } old := time.Now().Add(-48 * time.Hour) if err := os.Chtimes(orphan, old, old); err != nil { t.Fatalf("touch orphan: %v", err) } // Свежий файл без записи не трогаем: он может ещё не получить запись. fresh := filepath.Join(filesDir, "fresh.bin") if err := os.WriteFile(fresh, []byte("новый"), 0o600); err != nil { t.Fatalf("write fresh: %v", err) } runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24}) stats, err := runner.RunOnce(ctx) if err != nil { t.Fatalf("cleanup: %v", err) } if stats.Unknown != 1 { t.Fatalf("удалено файлов без записи: %d, ожидался 1", stats.Unknown) } if _, err := os.Stat(orphan); !os.IsNotExist(err) { t.Fatalf("файл без записи остался: %v", err) } if _, err := os.Stat(fresh); err != nil { t.Fatalf("свежий файл удалён: %v", err) } // Пустой подкаталог после уборки удалён. if _, err := os.Stat(nested); !os.IsNotExist(err) { t.Fatalf("пустой каталог остался: %v", err) } }