feat: add bucket cors configuration

Refs: https://github.com/rustfs/rustfs/issues/2330
This commit is contained in:
马登山
2026-04-02 11:04:28 +08:00
parent 5b4f4d5317
commit e92ee1c2b8
7 changed files with 448 additions and 2 deletions
+146 -2
View File
@@ -12,7 +12,7 @@ import { Switch } from "@/components/ui/switch"
import { Spinner } from "@/components/ui/spinner"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -29,6 +29,7 @@ import {
import { useMessage } from "@/lib/feedback/message"
import { formatBytes, getBytes } from "@/lib/functions"
import { detectBucketPolicy, setBucketPolicy, type BucketPolicyType } from "@/lib/bucket-policy"
import { stringifyBucketCorsConfig, validateBucketCorsJson, type BucketCorsConfiguration } from "@/lib/bucket-cors"
import { cn } from "@/lib/utils"
interface BucketInfoProps {
@@ -69,6 +70,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const canDeleteBucketPolicy = canCapability("bucket.policy.delete", bucketContext)
const canEditBucketPolicy = canPutBucketPolicy || canDeleteBucketPolicy
const canEditEncryption = canCapability("bucket.encryption.edit", bucketContext)
const canEditCors = canCapability("bucket.cors.edit", bucketContext)
const canEditTags = canCapability("bucket.tag.edit", bucketContext)
const canEditVersioning = canCapability("bucket.versioning.edit", bucketContext)
const canEditQuota = canCapability("bucket.quota.edit", bucketContext)
@@ -77,6 +79,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const [policy, setPolicy] = React.useState<string | null>(null)
const [policyType, setPolicyType] = React.useState<BucketPolicyType | "custom">("private")
const [encryption, setEncryption] = React.useState<Record<string, unknown> | null>(null)
const [corsConfig, setCorsConfig] = React.useState<BucketCorsConfiguration | null>(null)
const [tags, setTags] = React.useState<Tag[]>([])
const [objectLock, setObjectLock] = React.useState<boolean | null>(null)
const [versioning, setVersioning] = React.useState<string | null>(null)
@@ -97,6 +100,10 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const [encryptFormKmsKeyId, setEncryptFormKmsKeyId] = React.useState("")
const [kmsKeyOptions, setKmsKeyOptions] = React.useState<{ label: string; value: string }[]>([])
const [showCorsModal, setShowCorsModal] = React.useState(false)
const [corsFormEnabled, setCorsFormEnabled] = React.useState(false)
const [corsFormContent, setCorsFormContent] = React.useState(stringifyBucketCorsConfig())
const [showTagModal, setShowTagModal] = React.useState(false)
const [tagFormKey, setTagFormKey] = React.useState("")
const [tagFormValue, setTagFormValue] = React.useState("")
@@ -113,13 +120,19 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const [retentionPeriodInput, setRetentionPeriodInput] = React.useState("")
const [deleteTagIndex, setDeleteTagIndex] = React.useState<number | null>(null)
const corsValidation = React.useMemo(
() => (corsFormEnabled ? validateBucketCorsJson(corsFormContent) : { config: null, error: null }),
[corsFormContent, corsFormEnabled],
)
const fetchData = React.useCallback(async () => {
if (!bucketName) return
setLoading(true)
try {
const [p, e, tagResp, lockResp, verResp, quotaResp] = await Promise.all([
const [p, e, corsResp, tagResp, lockResp, verResp, quotaResp] = await Promise.all([
bucketApi.getBucketPolicy(bucketName).catch(() => null),
bucketApi.getBucketEncryption(bucketName).catch(() => null),
bucketApi.getBucketCors(bucketName).catch(() => null),
bucketApi.getBucketTagging(bucketName).catch(() => null),
bucketApi.getObjectLockConfiguration(bucketName).catch(() => null),
bucketApi.getBucketVersioning(bucketName).catch(() => null),
@@ -146,6 +159,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
setEncryption(e as unknown as Record<string, unknown>)
setCorsConfig(
((corsResp as BucketCorsConfiguration | null)?.CORSRules?.length ?? 0) > 0
? (corsResp as BucketCorsConfiguration)
: null,
)
setTags(
(tagResp as { TagSet?: Array<{ Key?: string; Value?: string }> })?.TagSet?.map((x) => ({
Key: x.Key ?? "",
@@ -302,6 +320,38 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
}
const openCorsModal = () => {
if (!canEditCors) return
setCorsFormEnabled(Boolean(corsConfig?.CORSRules?.length))
setCorsFormContent(stringifyBucketCorsConfig(corsConfig))
setShowCorsModal(true)
}
const submitCors = async () => {
if (!canEditCors) return
try {
if (!corsFormEnabled) {
if (corsConfig?.CORSRules?.length) {
await bucketApi.deleteBucketCors(bucketName)
}
} else {
if (corsValidation.error || !corsValidation.config) {
message.error(corsValidation.error ?? t("Invalid CORS configuration"))
return
}
await bucketApi.putBucketCors(bucketName, corsValidation.config)
}
message.success(t("Edit Success"))
setShowCorsModal(false)
await fetchData()
} catch (err) {
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
}
}
const openTagModal = (index = -1) => {
if (!canEditTags) return
setEditingTagIndex(index)
@@ -460,6 +510,9 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
: policyType
const encryptionLabel = parseEncryptionLabel(encryption as Parameters<typeof parseEncryptionLabel>[0])
const corsRules = corsConfig?.CORSRules ?? []
const corsMethods = Array.from(new Set(corsRules.flatMap((rule) => rule.AllowedMethods ?? [])))
const corsOrigins = Array.from(new Set(corsRules.flatMap((rule) => rule.AllowedOrigins ?? [])))
if (loading) {
return (
@@ -508,6 +561,46 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
</ItemHeader>
</Item>
{/* CORS */}
<Item variant="outline" className="flex-col items-stretch gap-4">
<ItemHeader className="items-start">
<div className="flex flex-col gap-1">
<ItemTitle>{t("Bucket CORS")}</ItemTitle>
<ItemDescription className="text-xs text-muted-foreground">
{corsRules.length > 0 ? t("Configured") : t("Not Enabled")}
</ItemDescription>
</div>
<ItemActions>
{canEditCors ? (
<Button variant="outline" size="sm" className="shrink-0" onClick={openCorsModal}>
<RiEdit2Line className="me-2 size-4" />
{t("Edit")}
</Button>
) : null}
</ItemActions>
</ItemHeader>
<ItemContent>
{corsRules.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">{t("CORS Rules")}</p>
<p className="text-sm text-foreground">{corsRules.length}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("Allowed Methods")}</p>
<p className="text-sm text-foreground">{corsMethods.join(", ") || "-"}</p>
</div>
<div className="sm:col-span-2">
<p className="text-xs text-muted-foreground">{t("Allowed Origins")}</p>
<p className="break-all text-sm text-foreground">{corsOrigins.join(", ") || "-"}</p>
</div>
</div>
) : (
<ItemDescription className="text-sm">{t("No Data")}</ItemDescription>
)}
</ItemContent>
</Item>
{/* Tag */}
<Item variant="outline" className="flex-col items-stretch gap-4">
<ItemHeader className="items-center">
@@ -750,6 +843,57 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
</DialogContent>
</Dialog>
{/* CORS Modal */}
<Dialog open={showCorsModal} onOpenChange={setShowCorsModal}>
<DialogContent className="sm:max-w-2xl" showCloseButton>
<DialogHeader>
<DialogTitle>{t("Set Bucket CORS")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Field className="flex items-center justify-between">
<FieldLabel>{t("Bucket CORS")}</FieldLabel>
<FieldContent className="flex justify-end">
<Switch checked={corsFormEnabled} onCheckedChange={setCorsFormEnabled} />
</FieldContent>
</Field>
{corsFormEnabled ? (
<Field>
<FieldLabel>{t("CORS Configuration")}</FieldLabel>
<FieldContent>
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-2">
<Textarea
value={corsFormContent}
onChange={(e) => setCorsFormContent(e.target.value)}
className="min-h-[260px] font-mono text-xs"
/>
</div>
</FieldContent>
<FieldDescription>
{t('CORS JSON must be an array of rules or an object with a "CORSRules" array.')}
</FieldDescription>
{corsValidation.error ? (
<FieldDescription className="text-destructive">{corsValidation.error}</FieldDescription>
) : (
<FieldDescription>{t("CORS JSON validation passed")}</FieldDescription>
)}
</Field>
) : (
<FieldDescription>
{t("Disable Bucket CORS to remove the current cross-origin configuration.")}
</FieldDescription>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCorsModal(false)}>
{t("Cancel")}
</Button>
<Button onClick={submitCors} disabled={corsFormEnabled && Boolean(corsValidation.error)}>
{t("Confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Tag Modal */}
<Dialog open={showTagModal} onOpenChange={setShowTagModal}>
<DialogContent className="sm:max-w-md" showCloseButton>
+32
View File
@@ -4,7 +4,9 @@ import { useCallback } from "react"
import {
CreateBucketCommand,
DeleteBucketCommand,
DeleteBucketCorsCommand,
GetBucketEncryptionCommand,
GetBucketCorsCommand,
GetBucketLifecycleConfigurationCommand,
GetBucketPolicyCommand,
GetBucketPolicyStatusCommand,
@@ -16,6 +18,7 @@ import {
HeadBucketCommand,
ListBucketsCommand,
type ListBucketsCommandOutput,
PutBucketCorsCommand,
PutBucketEncryptionCommand,
PutBucketLifecycleConfigurationCommand,
PutBucketPolicyCommand,
@@ -284,6 +287,32 @@ export function useBucket() {
[client],
)
const getBucketCors = useCallback(
async (bucket: string) => {
return client.send(new GetBucketCorsCommand({ Bucket: bucket }))
},
[client],
)
const putBucketCors = useCallback(
async (bucket: string, corsConfiguration: unknown) => {
return client.send(
new PutBucketCorsCommand({
Bucket: bucket,
CORSConfiguration: corsConfiguration as never,
}),
)
},
[client],
)
const deleteBucketCors = useCallback(
async (bucket: string) => {
return client.send(new DeleteBucketCorsCommand({ Bucket: bucket }))
},
[client],
)
const getBucketReplication = useCallback(
async (bucket: string) => {
return client.send(new GetBucketReplicationCommand({ Bucket: bucket }))
@@ -376,6 +405,9 @@ export function useBucket() {
getBucketEncryption,
putBucketEncryption,
deleteBucketEncryption,
getBucketCors,
putBucketCors,
deleteBucketCors,
getBucketReplication,
putBucketReplication,
deleteBucketReplication,
+11
View File
@@ -42,6 +42,8 @@
"Advanced Monitoring": "Advanced Monitoring",
"Advanced Settings": "Advanced Settings",
"Algorithm": "Algorithm",
"Allowed Methods": "Allowed Methods",
"Allowed Origins": "Allowed Origins",
"Amazon Resource Name": "Amazon Resource Name",
"Apache License": "Apache License",
"AppRole": "AppRole",
@@ -83,6 +85,7 @@
"Bucket": "Bucket",
"Bucket Configuration": "Bucket Configuration",
"Bucket Count": "Bucket Count",
"Bucket CORS": "Bucket CORS",
"Bucket Encryption Management": "Bucket Encryption Management",
"Bucket Events": "Bucket Events",
"Bucket Notification": "Bucket Notification",
@@ -93,6 +96,7 @@
"Bucket Quota Insufficient": "Bucket Quota Insufficient",
"Quota Warning Content": "The total size of the selected files is {total}, which exceeds the remaining bucket quota {remaining}. Do you want to continue?",
"Continue Upload": "Continue Upload",
"Set Bucket CORS": "Set Bucket CORS",
"Set Bucket Quota": "Set Bucket Quota",
"Bucket Replication": "Bucket Replication",
"Bucket Setting": "Bucket Setting",
@@ -103,6 +107,10 @@
"Buckets": "Buckets",
"COMMENT_KEY": "Comment",
"COMPLIANCE": "COMPLIANCE",
"CORS Configuration": "CORS Configuration",
"CORS JSON must be an array of rules or an object with a \"CORSRules\" array.": "CORS JSON must be an array of rules or an object with a \"CORSRules\" array.",
"CORS JSON validation passed": "CORS JSON validation passed",
"CORS Rules": "CORS Rules",
"Cache Enabled": "Cache Enabled",
"Cache Hits": "Cache Hits",
"Cache Misses": "Cache Misses",
@@ -201,6 +209,7 @@
"Delete Selected": "Delete Selected",
"Delete Success": "Delete Success",
"Delete Tag Confirm": "Delete Tag Confirm",
"Disable Bucket CORS to remove the current cross-origin configuration.": "Disable Bucket CORS to remove the current cross-origin configuration.",
"Deleting": "Deleting({count})",
"Deleting...": "Deleting...",
"Description": "Description",
@@ -332,6 +341,7 @@
"Info": "Info",
"Infrastructure Health": "Infrastructure Health",
"Inspect individual server health, disk utilization, and network status.": "Inspect individual server health, disk utilization, and network status.",
"Invalid CORS configuration": "Invalid CORS configuration",
"Invalid server address format": "Invalid server address format",
"JSON Editor": "JSON Editor",
"KMS Configuration": "KMS Configuration",
@@ -471,6 +481,7 @@
"Non-current Version": "Non-current Version",
"Normal": "Normal",
"Not Configured": "Not Configured",
"Not Enabled": "Not Enabled",
"Not configured": "Not configured",
"Not specified": "Not specified",
"Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites": "Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites",
+11
View File
@@ -45,6 +45,8 @@
"Advanced Monitoring": "高级监控",
"Advanced Settings": "高级设置",
"Algorithm": "算法",
"Allowed Methods": "允许的方法",
"Allowed Origins": "允许的来源",
"Amazon Resource Name": "Amazon资源名称",
"Apache License": "Apache许可证",
"AppRole": "应用角色",
@@ -87,6 +89,7 @@
"Bucket": "存储桶",
"Bucket Configuration": "桶配置",
"Bucket Count": "存储桶数量",
"Bucket CORS": "存储桶跨域配置",
"Bucket Encryption Management": "存储桶加密管理",
"Bucket Events": "存储桶事件",
"Bucket Notification": "存储桶通知",
@@ -94,6 +97,7 @@
"Bucket Quota": "存储桶配额",
"Bucket Quota Exceeded": "存储桶配额已超出",
"Bucket Quota Insufficient": "存储桶配额不足",
"Set Bucket CORS": "设置存储桶跨域配置",
"Bucket Replication": "存储桶复制",
"Bucket Setting": "存储桶设置",
"Bucket encryption configured successfully": "存储桶加密配置成功",
@@ -103,6 +107,10 @@
"Buckets": "存储桶",
"COMMENT_KEY": "注释",
"COMPLIANCE": "合规",
"CORS Configuration": "跨域配置",
"CORS JSON must be an array of rules or an object with a \"CORSRules\" array.": "CORS JSON 必须是规则数组,或包含 \"CORSRules\" 数组的对象。",
"CORS JSON validation passed": "CORS JSON 校验通过",
"CORS Rules": "跨域规则数",
"Cache Enabled": "已启用缓存",
"Cache Hits": "缓存命中",
"Cache Misses": "缓存未命中",
@@ -204,6 +212,7 @@
"Delete Selected": "删除选中项",
"Delete Success": "删除成功",
"Delete Tag Confirm": "你确定要删除这个标签吗?",
"Disable Bucket CORS to remove the current cross-origin configuration.": "关闭存储桶跨域配置后,将移除当前的跨域设置。",
"Deleting": "进行中({count})",
"Deleting...": "删除中...",
"Description": "描述",
@@ -342,6 +351,7 @@
"Infrastructure Health": "基础设施健康",
"Inherited from group": "继承自分组",
"Inspect individual server health, disk utilization, and network status.": "检查单个服务器健康、磁盘利用率和网络状态。",
"Invalid CORS configuration": "跨域配置无效",
"Invalid JSON format": "JSON 格式无效",
"Invalid server address format": "无效的服务器地址格式",
"JSON Editor": "JSON 编辑器",
@@ -486,6 +496,7 @@
"Non-current Version": "非当前版本",
"Normal": "普通",
"Not Configured": "未配置",
"Not Enabled": "未启用",
"Not configured": "未配置",
"Not specified": "未指定",
"Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites": "注意:添加或编辑对等站点时,每个站点的 AccessKey 和 SecretKey 值都是必需的",
+170
View File
@@ -0,0 +1,170 @@
import type { CORSRule } from "@aws-sdk/client-s3"
export interface BucketCorsConfiguration {
CORSRules: CORSRule[]
}
interface BucketCorsValidationResult {
config: BucketCorsConfiguration | null
error: string | null
}
const ALLOWED_METHODS = new Set(["GET", "PUT", "POST", "DELETE", "HEAD"])
const RULE_KEYS = new Set([
"ID",
"AllowedHeaders",
"AllowedMethods",
"AllowedOrigins",
"ExposeHeaders",
"MaxAgeSeconds",
])
export const DEFAULT_BUCKET_CORS_CONFIGURATION: BucketCorsConfiguration = {
CORSRules: [
{
AllowedOrigins: ["*"],
AllowedMethods: ["GET", "PUT", "POST", "DELETE", "HEAD"],
AllowedHeaders: ["*"],
ExposeHeaders: ["ETag"],
},
],
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function parseStringArray(
value: unknown,
fieldName: string,
options: { required?: boolean } = {},
): string[] | undefined {
if (value == null) {
if (options.required) {
throw new Error(`${fieldName} is required`)
}
return undefined
}
if (!Array.isArray(value)) {
throw new Error(`${fieldName} must be an array of strings`)
}
const normalized = value.map((item, index) => {
if (typeof item !== "string") {
throw new Error(`${fieldName}[${index}] must be a string`)
}
const trimmed = item.trim()
if (!trimmed) {
throw new Error(`${fieldName}[${index}] cannot be empty`)
}
return trimmed
})
if (options.required && normalized.length === 0) {
throw new Error(`${fieldName} must contain at least one value`)
}
return normalized
}
function normalizeRule(rule: unknown, index: number): CORSRule {
if (!isRecord(rule)) {
throw new Error(`CORSRules[${index}] must be an object`)
}
for (const key of Object.keys(rule)) {
if (!RULE_KEYS.has(key)) {
throw new Error(`CORSRules[${index}] contains unsupported field "${key}"`)
}
}
const allowedOrigins =
parseStringArray(rule.AllowedOrigins, `CORSRules[${index}].AllowedOrigins`, { required: true }) ?? []
const allowedMethods =
parseStringArray(rule.AllowedMethods, `CORSRules[${index}].AllowedMethods`, { required: true }) ?? []
for (const method of allowedMethods) {
if (!ALLOWED_METHODS.has(method)) {
throw new Error(
`CORSRules[${index}].AllowedMethods contains invalid method "${method}". Allowed values: GET, PUT, POST, DELETE, HEAD`,
)
}
}
const allowedHeaders = parseStringArray(rule.AllowedHeaders, `CORSRules[${index}].AllowedHeaders`)
const exposeHeaders = parseStringArray(rule.ExposeHeaders, `CORSRules[${index}].ExposeHeaders`)
if (rule.ID != null && typeof rule.ID !== "string") {
throw new Error(`CORSRules[${index}].ID must be a string`)
}
const id = typeof rule.ID === "string" ? rule.ID.trim() : undefined
if (id && id.length > 255) {
throw new Error(`CORSRules[${index}].ID cannot be longer than 255 characters`)
}
if (rule.MaxAgeSeconds != null) {
if (typeof rule.MaxAgeSeconds !== "number" || !Number.isInteger(rule.MaxAgeSeconds) || rule.MaxAgeSeconds < 0) {
throw new Error(`CORSRules[${index}].MaxAgeSeconds must be a non-negative integer`)
}
}
return {
...(id ? { ID: id } : {}),
...(allowedHeaders ? { AllowedHeaders: allowedHeaders } : {}),
AllowedMethods: allowedMethods,
AllowedOrigins: allowedOrigins,
...(exposeHeaders ? { ExposeHeaders: exposeHeaders } : {}),
...(rule.MaxAgeSeconds != null ? { MaxAgeSeconds: rule.MaxAgeSeconds } : {}),
}
}
export function normalizeBucketCorsConfig(value: unknown): BucketCorsConfiguration {
const rawRules = Array.isArray(value) ? value : isRecord(value) ? value.CORSRules : undefined
if (!rawRules) {
throw new Error('CORS JSON must be an array of rules or an object with a "CORSRules" array')
}
if (!Array.isArray(rawRules)) {
throw new Error("CORSRules must be an array")
}
if (rawRules.length === 0) {
throw new Error("CORSRules must contain at least one rule")
}
return {
CORSRules: rawRules.map((rule, index) => normalizeRule(rule, index)),
}
}
export function validateBucketCorsJson(content: string): BucketCorsValidationResult {
if (!content.trim()) {
return {
config: null,
error: "CORS JSON cannot be empty",
}
}
try {
const parsed = JSON.parse(content) as unknown
const config = normalizeBucketCorsConfig(parsed)
return {
config,
error: null,
}
} catch (error) {
return {
config: null,
error: error instanceof Error ? error.message : "Invalid CORS JSON",
}
}
}
export function stringifyBucketCorsConfig(config?: BucketCorsConfiguration | null): string {
return JSON.stringify(config?.CORSRules ?? DEFAULT_BUCKET_CORS_CONFIGURATION.CORSRules, null, 2)
}
+2
View File
@@ -14,6 +14,7 @@ export type ConsoleCapability =
| "bucket.policy.delete"
| "bucket.policy.edit"
| "bucket.encryption.edit"
| "bucket.cors.edit"
| "bucket.tag.edit"
| "bucket.versioning.edit"
| "bucket.objectLock.edit"
@@ -63,6 +64,7 @@ const CAPABILITY_REQUIREMENTS: Record<ConsoleCapability, CapabilityRequirement[]
"bucket.policy.delete": [{ actions: ["s3:DeleteBucketPolicy"], resource: "bucket" }],
"bucket.policy.edit": [{ actions: ["s3:PutBucketPolicy", "s3:DeleteBucketPolicy"], mode: "any", resource: "bucket" }],
"bucket.encryption.edit": [{ actions: ["s3:PutBucketEncryption"], resource: "bucket" }],
"bucket.cors.edit": [{ actions: ["s3:PutBucketCORS"], resource: "bucket" }],
"bucket.tag.edit": [{ actions: ["s3:PutBucketTagging"], resource: "bucket" }],
"bucket.versioning.edit": [{ actions: ["s3:PutBucketVersioning"], resource: "bucket" }],
"bucket.objectLock.edit": [{ actions: ["s3:PutBucketObjectLockConfiguration"], resource: "bucket" }],
+76
View File
@@ -0,0 +1,76 @@
import test from "node:test"
import assert from "node:assert/strict"
import { normalizeBucketCorsConfig, stringifyBucketCorsConfig, validateBucketCorsJson } from "../../lib/bucket-cors"
test("normalizeBucketCorsConfig accepts an object with CORSRules", () => {
const config = normalizeBucketCorsConfig({
CORSRules: [
{
AllowedOrigins: ["https://example.com"],
AllowedMethods: ["GET", "PUT"],
MaxAgeSeconds: 3600,
},
],
})
assert.deepEqual(config, {
CORSRules: [
{
AllowedOrigins: ["https://example.com"],
AllowedMethods: ["GET", "PUT"],
MaxAgeSeconds: 3600,
},
],
})
})
test("validateBucketCorsJson accepts a plain array of rules", () => {
const result = validateBucketCorsJson(
JSON.stringify([
{
AllowedOrigins: ["*"],
AllowedMethods: ["GET"],
},
]),
)
assert.equal(result.error, null)
assert.deepEqual(result.config, {
CORSRules: [
{
AllowedOrigins: ["*"],
AllowedMethods: ["GET"],
},
],
})
})
test("validateBucketCorsJson rejects invalid methods", () => {
const result = validateBucketCorsJson(
JSON.stringify({
CORSRules: [
{
AllowedOrigins: ["https://example.com"],
AllowedMethods: ["PATCH"],
},
],
}),
)
assert.equal(result.config, null)
assert.match(result.error ?? "", /invalid method "PATCH"/)
})
test("stringifyBucketCorsConfig returns formatted json", () => {
const text = stringifyBucketCorsConfig({
CORSRules: [
{
AllowedOrigins: ["https://example.com"],
AllowedMethods: ["GET"],
},
],
})
assert.match(text, /"CORSRules"/)
assert.match(text, /"AllowedOrigins"/)
})