From 7ad5c7761d16299f7ef4a8ddef9479f873362ac1 Mon Sep 17 00:00:00 2001 From: cxymds Date: Fri, 17 Jul 2026 17:50:39 +0800 Subject: [PATCH] fix(lifecycle): preserve expiration days with marker cleanup (#170) --- components/lifecycle/new-form.tsx | 167 ++++++++++++++++++----- i18n/locales/ar-MA.json | 5 +- i18n/locales/de-DE.json | 5 +- i18n/locales/en-US.json | 5 +- i18n/locales/es-ES.json | 5 +- i18n/locales/fr-FR.json | 5 +- i18n/locales/id-ID.json | 5 +- i18n/locales/it-IT.json | 5 +- i18n/locales/ja-JP.json | 5 +- i18n/locales/ko-KR.json | 5 +- i18n/locales/pt-BR.json | 5 +- i18n/locales/ru-RU.json | 5 +- i18n/locales/tr-TR.json | 5 +- i18n/locales/vi-VN.json | 5 +- i18n/locales/zh-CN.json | 5 +- lib/bucket-lifecycle.ts | 47 ++++++- tests/lib/bucket-settings-safety.test.js | 91 +++++++++++- 17 files changed, 317 insertions(+), 58 deletions(-) diff --git a/components/lifecycle/new-form.tsx b/components/lifecycle/new-form.tsx index 5c276bb..fa19cd7 100644 --- a/components/lifecycle/new-form.tsx +++ b/components/lifecycle/new-form.tsx @@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next" import { RiAddLine, RiDeleteBinLine } from "@remixicon/react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" @@ -16,7 +16,15 @@ import { useTiers, type TierRow } from "@/hooks/use-tiers" import { useMessage } from "@/lib/feedback/message" import { randomUUID } from "@/lib/functions" import { isMissingBucketConfiguration } from "@/lib/bucket-configuration" -import { buildCurrentVersionExpiration, buildLifecycleFilter } from "@/lib/bucket-lifecycle" +import { + buildCurrentVersionExpirationRules, + buildLifecycleFilter, + findIncompleteLifecycleTag, + getBucketVersioningMode, + hasCompleteLifecycleTags, + MAX_LIFECYCLE_RULES, + type BucketVersioningMode, +} from "@/lib/bucket-lifecycle" interface LifecycleNewFormProps { open: boolean @@ -47,13 +55,20 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: const [tiersLoading, setTiersLoading] = useState(false) const [tiersError, setTiersError] = useState("") const [tiersReloadVersion, setTiersReloadVersion] = useState(0) - const [versioningStatus, setVersioningStatus] = useState(false) + const [versioningMode, setVersioningMode] = useState("unversioned") const [versioningLoading, setVersioningLoading] = useState(false) const [versioningError, setVersioningError] = useState("") const [versioningReloadVersion, setVersioningReloadVersion] = useState(0) const [submitting, setSubmitting] = useState(false) const [saveError, setSaveError] = useState("") - const [fieldErrors, setFieldErrors] = useState<{ days?: string; storageType?: string }>({}) + const [fieldErrors, setFieldErrors] = useState<{ + days?: string + storageType?: string + tags?: string + deleteMarker?: string + }>({}) + + const hasVersionHistory = versioningMode !== "unversioned" const versionOptions = useMemo( () => [ @@ -89,16 +104,16 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: const loadVersioningStatus = useCallback(async () => { if (!bucketName) { - setVersioningStatus(false) + setVersioningMode("unversioned") return } setVersioningLoading(true) setVersioningError("") try { const resp = await getBucketVersioning(bucketName) - setVersioningStatus(resp.Status === "Enabled") + setVersioningMode(getBucketVersioningMode(resp.Status)) } catch { - setVersioningStatus(false) + setVersioningMode("unversioned") setVersioningError(t("Unable to load versioning status.")) } finally { setVersioningLoading(false) @@ -113,8 +128,15 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: }, [open, loadTiers, loadVersioningStatus, tiersReloadVersion, versioningReloadVersion]) useEffect(() => { - if (versionType !== "current") setExpiredDeleteMark(false) - }, [versionType]) + if (activeTab !== "expire" || versionType !== "current") setExpiredDeleteMark(false) + }, [activeTab, versionType]) + + useEffect(() => { + if (!hasVersionHistory) { + setVersionType("current") + setExpiredDeleteMark(false) + } + }, [hasVersionHistory]) const resetForm = useCallback(() => { setActiveTab("expire") @@ -141,20 +163,31 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: const removeTag = (index: number) => { if (tags.length === 1) return setTags((prev) => prev.filter((_, i) => i !== index)) + setFieldErrors((current) => ({ ...current, tags: undefined, deleteMarker: undefined })) } const updateTag = (index: number, field: "key" | "value", value: string) => { setTags((prev) => prev.map((tag, i) => (i === index ? { ...tag, [field]: value } : tag))) + setFieldErrors((current) => ({ ...current, tags: undefined, deleteMarker: undefined })) } const validate = () => { - const errors: { days?: string; storageType?: string } = {} + const errors: typeof fieldErrors = {} if (!days || days < 1) { errors.days = t("Please enter valid days") } if (activeTab === "transition" && !storageType) { errors.storageType = t("Please select storage type") } + + const incompleteTagIndex = findIncompleteLifecycleTag(tags) + if (incompleteTagIndex >= 0) { + errors.tags = tags[incompleteTagIndex].key.trim() ? t("Please enter tag value") : t("Please enter key name") + } + if (activeTab === "expire" && versionType === "current" && expiredDeleteMark && hasCompleteLifecycleTags(tags)) { + errors.deleteMarker = `${t("Delete Marker Handling")}: ${t("Tags")} ${t("Unsupported")}` + } + setFieldErrors(errors) const firstErrorId = errors.days ? activeTab === "transition" @@ -162,7 +195,13 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: : "lifecycle-expire-days" : errors.storageType ? "lifecycle-storage-type" - : null + : errors.tags && incompleteTagIndex >= 0 + ? tags[incompleteTagIndex].key.trim() + ? `lifecycle-${activeTab}-tag-value-${incompleteTagIndex}` + : `lifecycle-${activeTab}-tag-key-${incompleteTagIndex}` + : errors.deleteMarker + ? "lifecycle-expired-delete-marker" + : null if (firstErrorId) document.getElementById(firstErrorId)?.focus() return !firstErrorId } @@ -189,30 +228,38 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: } } - const newRule: Record = { - ID: randomUUID(), + const filter = buildLifecycleFilter(prefix, tags) + const baseId = randomUUID() + const baseRule: Record = { + ID: baseId, Status: "Enabled", - Filter: buildLifecycleFilter(prefix, tags), + Filter: filter, } const daysValue = Number(days) + let newRules: Record[] if (activeTab === "expire") { if (versionType === "non-current") { - newRule.NoncurrentVersionExpiration = { NoncurrentDays: daysValue } + baseRule.NoncurrentVersionExpiration = { NoncurrentDays: daysValue } + newRules = [baseRule] } else { - newRule.Expiration = buildCurrentVersionExpiration(daysValue, expiredDeleteMark) + newRules = buildCurrentVersionExpirationRules(baseId, daysValue, filter, expiredDeleteMark) } } else { if (versionType === "non-current") { - newRule.NoncurrentVersionTransitions = [{ NoncurrentDays: daysValue, StorageClass: storageType }] + baseRule.NoncurrentVersionTransitions = [{ NoncurrentDays: daysValue, StorageClass: storageType }] } else { - newRule.Transitions = [{ Days: daysValue, StorageClass: storageType }] + baseRule.Transitions = [{ Days: daysValue, StorageClass: storageType }] } + newRules = [baseRule] } const existingRules = currentConfig?.Rules ?? [] - const payload = { Rules: [...existingRules, newRule] } + if (existingRules.length + newRules.length > MAX_LIFECYCLE_RULES) { + throw new Error(t("Lifecycle configuration cannot contain more than 1000 rules.")) + } + const payload = { Rules: [...existingRules, ...newRules] } await putBucketLifecycleConfiguration(bucketName, payload) message.success(t("Create Success")) @@ -309,7 +356,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
- {versioningStatus && ( + {hasVersionHistory && ( {t("Object Version")} @@ -378,7 +425,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: -
+
{t("Tags")}
)} + {fieldErrors.tags}
- {versionType === "current" && ( + {hasVersionHistory && versionType === "current" && (
{t("Advanced Settings")} -
-
- - {t("Delete Marker Handling")} - - +
- -
+ + + {fieldErrors.deleteMarker}
)} + + {hasVersionHistory && versionType === "current" ? ( + + {t("Warning")} + + {t( + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.", + )} + + + ) : null} + + {activeTab === "expire" && expiredDeleteMark ? ( +

{t("2 lifecycle rules will be created.")}

+ ) : null}
- {versioningStatus && ( + {hasVersionHistory && ( {t("Object Version")} @@ -561,7 +645,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }: -
+
{t("Tags")}
)} + {fieldErrors.tags}
diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 8449cb0..e44d1e5 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "يؤدي انتهاء صلاحية الإصدار الحالي إلى إنشاء علامة حذف. قم بتكوين انتهاء صلاحية الإصدارات غير الحالية لإزالة بيانات الكائن نهائيًا.", + "2 lifecycle rules will be created.": "سيتم إنشاء قاعدتين لدورة الحياة.", + "Lifecycle configuration cannot contain more than 1000 rules.": "لا يمكن أن يحتوي تكوين دورة الحياة على أكثر من 1000 قاعدة." } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 220e64b..45ef497 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Beim Ablauf der aktuellen Version wird eine Löschmarkierung erstellt. Konfigurieren Sie den Ablauf nicht aktueller Versionen, um Objektdaten dauerhaft zu entfernen.", + "2 lifecycle rules will be created.": "Es werden 2 Lebenszyklusregeln erstellt.", + "Lifecycle configuration cannot contain more than 1000 rules.": "Die Lebenszykluskonfiguration darf nicht mehr als 1000 Regeln enthalten." } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index e2d3e21..b6960ab 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.", + "2 lifecycle rules will be created.": "2 lifecycle rules will be created.", + "Lifecycle configuration cannot contain more than 1000 rules.": "Lifecycle configuration cannot contain more than 1000 rules." } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 1e9ed96..eef9a51 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "La expiración de la versión actual crea un marcador de eliminación. Configura la expiración de versiones no actuales para eliminar permanentemente los datos del objeto.", + "2 lifecycle rules will be created.": "Se crearán 2 reglas de ciclo de vida.", + "Lifecycle configuration cannot contain more than 1000 rules.": "La configuración del ciclo de vida no puede contener más de 1000 reglas." } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 8e2225f..ca986c5 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "L’expiration de la version actuelle crée un marqueur de suppression. Configurez l’expiration des versions non actuelles pour supprimer définitivement les données de l’objet.", + "2 lifecycle rules will be created.": "2 règles de cycle de vie seront créées.", + "Lifecycle configuration cannot contain more than 1000 rules.": "La configuration du cycle de vie ne peut pas contenir plus de 1 000 règles." } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 5996a81..0140cce 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Kedaluwarsa versi saat ini membuat penanda penghapusan. Konfigurasikan kedaluwarsa versi nonaktif untuk menghapus data objek secara permanen.", + "2 lifecycle rules will be created.": "2 aturan siklus hidup akan dibuat.", + "Lifecycle configuration cannot contain more than 1000 rules.": "Konfigurasi siklus hidup tidak boleh berisi lebih dari 1000 aturan." } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 221c086..ef195ae 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "La scadenza della versione corrente crea un indicatore di eliminazione. Configura la scadenza delle versioni non correnti per rimuovere definitivamente i dati dell’oggetto.", + "2 lifecycle rules will be created.": "Verranno create 2 regole del ciclo di vita.", + "Lifecycle configuration cannot contain more than 1000 rules.": "La configurazione del ciclo di vita non può contenere più di 1000 regole." } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 4907d4b..181696b 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "現行バージョンの有効期限切れでは削除マーカーが作成されます。オブジェクトデータを完全に削除するには、非現行バージョンの有効期限を設定してください。", + "2 lifecycle rules will be created.": "2 件のライフサイクルルールが作成されます。", + "Lifecycle configuration cannot contain more than 1000 rules.": "ライフサイクル設定に含められるルールは 1000 件までです。" } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index a4dbb34..ea730b1 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "현재 버전 만료 시 삭제 마커가 생성됩니다. 객체 데이터를 영구적으로 삭제하려면 비현재 버전 만료를 구성하세요.", + "2 lifecycle rules will be created.": "수명 주기 규칙 2개가 생성됩니다.", + "Lifecycle configuration cannot contain more than 1000 rules.": "수명 주기 구성에는 1000개를 초과하는 규칙을 포함할 수 없습니다." } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 953b69e..c726622 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "A expiração da versão atual cria um marcador de exclusão. Configure a expiração de versões não atuais para remover permanentemente os dados do objeto.", + "2 lifecycle rules will be created.": "Serão criadas 2 regras de ciclo de vida.", + "Lifecycle configuration cannot contain more than 1000 rules.": "A configuração do ciclo de vida não pode conter mais de 1000 regras." } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index 49bb137..8191460 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Загрузить больше объектов", "Scroll to load more objects": "Прокрутить для загрузки объектов", "Back to top": "Перейти вверх", - "Go to bottom": "Перейти вниз" + "Go to bottom": "Перейти вниз", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "При истечении срока действия текущей версии создается маркер удаления. Настройте удаление неактуальных версий, чтобы окончательно удалить данные объекта.", + "2 lifecycle rules will be created.": "Будут созданы 2 правила жизненного цикла.", + "Lifecycle configuration cannot contain more than 1000 rules.": "Конфигурация жизненного цикла не может содержать более 1000 правил." } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 00a71d1..8a78060 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Geçerli sürümün süresi dolduğunda bir silme işareti oluşturulur. Nesne verilerini kalıcı olarak kaldırmak için geçerli olmayan sürümlerin süre sonunu yapılandırın.", + "2 lifecycle rules will be created.": "2 yaşam döngüsü kuralı oluşturulacak.", + "Lifecycle configuration cannot contain more than 1000 rules.": "Yaşam döngüsü yapılandırması 1000'den fazla kural içeremez." } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index b809084..ca07eff 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -1501,5 +1501,8 @@ "Loading more objects": "Loading more objects", "Scroll to load more objects": "Scroll to load more objects", "Back to top": "Back to top", - "Go to bottom": "Go to bottom" + "Go to bottom": "Go to bottom", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Việc hết hạn phiên bản hiện tại sẽ tạo dấu xóa. Hãy cấu hình hết hạn phiên bản không hiện tại để xóa vĩnh viễn dữ liệu đối tượng.", + "2 lifecycle rules will be created.": "Sẽ tạo 2 quy tắc vòng đời.", + "Lifecycle configuration cannot contain more than 1000 rules.": "Cấu hình vòng đời không được chứa quá 1000 quy tắc." } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 9c89738..cdc0e11 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -1501,5 +1501,8 @@ "Loading more objects": "正在加载更多对象", "Scroll to load more objects": "滚动以加载更多对象", "Back to top": "回到顶部", - "Go to bottom": "直达底部" + "Go to bottom": "直达底部", + "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "当前版本过期时会创建删除标记。请配置非当前版本过期规则,以永久删除对象数据。", + "2 lifecycle rules will be created.": "将创建 2 条生命周期规则。", + "Lifecycle configuration cannot contain more than 1000 rules.": "生命周期配置不能包含超过 1000 条规则。" } diff --git a/lib/bucket-lifecycle.ts b/lib/bucket-lifecycle.ts index b26af2f..2a17b6a 100644 --- a/lib/bucket-lifecycle.ts +++ b/lib/bucket-lifecycle.ts @@ -3,8 +3,26 @@ export interface LifecycleFilterTag { value: string } +export type BucketVersioningMode = "unversioned" | "enabled" | "suspended" + +export const MAX_LIFECYCLE_RULES = 1000 + +export function getBucketVersioningMode(status?: string): BucketVersioningMode { + if (status === "Enabled") return "enabled" + if (status === "Suspended") return "suspended" + return "unversioned" +} + +export function findIncompleteLifecycleTag(tags: LifecycleFilterTag[]): number { + return tags.findIndex((tag) => Boolean(tag.key.trim()) !== Boolean(tag.value.trim())) +} + +export function hasCompleteLifecycleTags(tags: LifecycleFilterTag[]): boolean { + return tags.some((tag) => Boolean(tag.key.trim()) && Boolean(tag.value.trim())) +} + export function buildLifecycleFilter(prefix: string, tags: LifecycleFilterTag[]): Record { - const validTags = tags.filter((tag) => tag.key && tag.value) + const validTags = tags.filter((tag) => tag.key.trim() && tag.value.trim()) if (!prefix && validTags.length === 0) return { Prefix: "" } const [firstTag] = validTags @@ -24,6 +42,29 @@ export function buildLifecycleFilter(prefix: string, tags: LifecycleFilterTag[]) return { Prefix: prefix } } -export function buildCurrentVersionExpiration(days: number, expiredDeleteMarker: boolean): Record { - return expiredDeleteMarker ? { ExpiredObjectDeleteMarker: true } : { Days: days } +export function buildCurrentVersionExpirationRules( + baseId: string, + days: number, + filter: Record, + cleanupExpiredDeleteMarkers: boolean, +): Record[] { + const rules: Record[] = [ + { + ID: `${baseId}-expiration`, + Status: "Enabled", + Filter: filter, + Expiration: { Days: days }, + }, + ] + + if (cleanupExpiredDeleteMarkers) { + rules.push({ + ID: `${baseId}-delete-marker`, + Status: "Enabled", + Filter: filter, + Expiration: { ExpiredObjectDeleteMarker: true }, + }) + } + + return rules } diff --git a/tests/lib/bucket-settings-safety.test.js b/tests/lib/bucket-settings-safety.test.js index 66176ab..6130a74 100644 --- a/tests/lib/bucket-settings-safety.test.js +++ b/tests/lib/bucket-settings-safety.test.js @@ -1,13 +1,21 @@ import test from "node:test" import assert from "node:assert/strict" import { readFile } from "node:fs/promises" +import { PutBucketLifecycleConfigurationCommand, S3Client } from "@aws-sdk/client-s3" import { createLatestRequestGate, isMissingBucketConfiguration, normalizeReplicationRulesForRolelessConfig, removeMatchingBucketRule, } from "../../lib/bucket-configuration.ts" -import { buildCurrentVersionExpiration, buildLifecycleFilter } from "../../lib/bucket-lifecycle.ts" +import { + buildCurrentVersionExpirationRules, + buildLifecycleFilter, + findIncompleteLifecycleTag, + getBucketVersioningMode, + hasCompleteLifecycleTags, + MAX_LIFECYCLE_RULES, +} from "../../lib/bucket-lifecycle.ts" import { getXmlErrorCode } from "../../lib/error-handler.ts" const bucketInfoSource = await readFile(new URL("../../components/buckets/info.tsx", import.meta.url), "utf8") @@ -84,11 +92,86 @@ test("latest request gate invalidates stale and unmounted responses", () => { assert.equal(gate.isCurrent(second), false) }) -test("lifecycle payload helpers always emit a valid all-bucket filter and exclusive expiration fields", () => { +test("lifecycle payload helpers keep current expiration and delete-marker cleanup in separate rules", () => { assert.deepEqual(buildLifecycleFilter("", [{ key: "", value: "" }]), { Prefix: "" }) assert.deepEqual(buildLifecycleFilter("logs/", []), { Prefix: "logs/" }) - assert.deepEqual(buildCurrentVersionExpiration(30, false), { Days: 30 }) - assert.deepEqual(buildCurrentVersionExpiration(30, true), { ExpiredObjectDeleteMarker: true }) + + const filter = { Prefix: "logs/" } + assert.deepEqual(buildCurrentVersionExpirationRules("rule", 30, filter, false), [ + { + ID: "rule-expiration", + Status: "Enabled", + Filter: filter, + Expiration: { Days: 30 }, + }, + ]) + assert.deepEqual(buildCurrentVersionExpirationRules("rule", 30, filter, true), [ + { + ID: "rule-expiration", + Status: "Enabled", + Filter: filter, + Expiration: { Days: 30 }, + }, + { + ID: "rule-delete-marker", + Status: "Enabled", + Filter: filter, + Expiration: { ExpiredObjectDeleteMarker: true }, + }, + ]) +}) + +test("AWS SDK serializes expiration days and delete-marker cleanup as two valid rules", async () => { + let requestBody = "" + const client = new S3Client({ + region: "us-east-1", + endpoint: "http://127.0.0.1:9000", + credentials: { accessKeyId: "test", secretAccessKey: "test" }, + requestHandler: { + async handle(request) { + requestBody = typeof request.body === "string" ? request.body : new TextDecoder().decode(request.body) + return { response: { statusCode: 200, headers: {}, body: new Uint8Array() } } + }, + }, + }) + const filter = { Prefix: "logs/" } + + await client.send( + new PutBucketLifecycleConfigurationCommand({ + Bucket: "test-bucket", + LifecycleConfiguration: { + Rules: buildCurrentVersionExpirationRules("rule", 30, filter, true), + }, + }), + ) + client.destroy() + + const expirations = [...requestBody.matchAll(/(.*?)<\/Expiration>/g)].map((match) => match[1]) + assert.equal((requestBody.match(//g) ?? []).length, 2) + assert.deepEqual(expirations, ["30", "true"]) +}) + +test("lifecycle helpers preserve suspended versioning and reject partial tag pairs", () => { + assert.equal(MAX_LIFECYCLE_RULES, 1000) + assert.equal(getBucketVersioningMode("Enabled"), "enabled") + assert.equal(getBucketVersioningMode("Suspended"), "suspended") + assert.equal(getBucketVersioningMode(undefined), "unversioned") + + assert.equal(findIncompleteLifecycleTag([{ key: "", value: "" }]), -1) + assert.equal(findIncompleteLifecycleTag([{ key: "env", value: "" }]), 0) + assert.equal(findIncompleteLifecycleTag([{ key: "", value: "prod" }]), 0) + assert.equal(hasCompleteLifecycleTags([{ key: "env", value: "prod" }]), true) + assert.equal(hasCompleteLifecycleTags([{ key: "", value: "" }]), false) + + assert.match( + lifecycleFormSource, + /buildCurrentVersionExpirationRules\(baseId, daysValue, filter, expiredDeleteMark\)/, + ) + assert.match(lifecycleFormSource, /Rules: \[\.\.\.existingRules, \.\.\.newRules\]/) + assert.match(lifecycleFormSource, /existingRules\.length \+ newRules\.length > MAX_LIFECYCLE_RULES/) + assert.match(lifecycleFormSource, /const hasVersionHistory = versioningMode !== "unversioned"/) + assert.match(lifecycleFormSource, /expiredDeleteMark && hasCompleteLifecycleTags\(tags\)/) + assert.match(lifecycleFormSource, /setExpiredDeleteMark\(checked === true\)/) }) test("bucket reads fail closed and stale responses cannot update the active bucket", () => {