Merge pull request #182 from Dvorinka/feat/storage-policy-adv
feat(storage): group-allowed policies, per-dir policy, load-balance policy, user-facing relocatepull/3587/head
commit
8c08df9530
File diff suppressed because one or more lines are too long
@ -0,0 +1,82 @@
|
||||
import { Box, Checkbox, FormControl, ListItemText, SelectChangeEvent } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStoragePolicyList } from "../../../../api/api";
|
||||
import { StoragePolicy } from "../../../../api/dashboard";
|
||||
import { useAppDispatch } from "../../../../redux/hooks";
|
||||
import { DenseSelect, SquareChip } from "../../../Common/StyledComponents";
|
||||
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
|
||||
|
||||
export interface PolicyMultiSelectionInputProps {
|
||||
value: number[];
|
||||
onChange: (value: number[]) => void;
|
||||
}
|
||||
|
||||
// PolicyMultiSelectionInput picks the pool of storage policies members of a
|
||||
// group may freely switch between. The group's default policy is always
|
||||
// available implicitly and need not be selected here.
|
||||
const PolicyMultiSelectionInput = ({ value, onChange }: PolicyMultiSelectionInputProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [policyMap, setPolicyMap] = useState<Record<number, StoragePolicy>>({});
|
||||
|
||||
const handleChange = (event: SelectChangeEvent<unknown>) => {
|
||||
const {
|
||||
target: { value: v },
|
||||
} = event;
|
||||
onChange(typeof v === "string" ? v.split(",").map((x) => parseInt(x)) : (v as number[]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
|
||||
.then((res) => {
|
||||
setPolicies(res.policies);
|
||||
setPolicyMap(
|
||||
res.policies.reduce(
|
||||
(acc, policy) => {
|
||||
acc[policy.id] = policy;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, StoragePolicy>,
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<DenseSelect
|
||||
multiple
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
sx={{
|
||||
minHeight: 39,
|
||||
}}
|
||||
disabled={loading}
|
||||
MenuProps={{
|
||||
PaperProps: { sx: { maxWidth: 300 } },
|
||||
}}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, py: 0.5 }}>
|
||||
{(selected as number[]).map((id) => (
|
||||
<SquareChip key={id} label={policyMap[id]?.name ?? id} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{policies.map((p) => (
|
||||
<SquareMenuItem key={p.id} value={p.id}>
|
||||
<Checkbox size="small" checked={value.indexOf(p.id) > -1} />
|
||||
<ListItemText primary={p.name} secondary={`#${p.id} · ${p.type}`} />
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</DenseSelect>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyMultiSelectionInput;
|
||||
@ -0,0 +1,160 @@
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
IconButton,
|
||||
SelectChangeEvent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStoragePolicyList } from "../../../../../api/api";
|
||||
import { LBPolicyRef, StoragePolicy } from "../../../../../api/dashboard";
|
||||
import { PolicyType } from "../../../../../api/explorer";
|
||||
import { useAppDispatch } from "../../../../../redux/hooks";
|
||||
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../../Common/StyledComponents";
|
||||
import { SquareMenuItem } from "../../../../FileManager/ContextMenu/ContextMenu";
|
||||
import Dismiss from "../../../../Icons/Dismiss";
|
||||
import SettingForm from "../../../../Pages/Setting/SettingForm";
|
||||
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../../Settings/Settings";
|
||||
import { StoragePolicySettingContext } from "../StoragePolicySettingWrapper";
|
||||
|
||||
// LoadBalanceSection edits the weighted child policies of a load_balance
|
||||
// policy. Only concrete (non-load-balance) policies can be children.
|
||||
const LoadBalanceSection = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { values, setPolicy } = useContext(StoragePolicySettingContext);
|
||||
const dispatch = useAppDispatch();
|
||||
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
|
||||
|
||||
const isLB = values.type === PolicyType.load_balance;
|
||||
const refs = values.settings?.lb_policies ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLB) {
|
||||
return;
|
||||
}
|
||||
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" })).then(
|
||||
(res) => {
|
||||
setPolicies(res.policies.filter((p) => p.type !== PolicyType.load_balance && p.id !== values.id));
|
||||
},
|
||||
);
|
||||
}, [isLB]);
|
||||
|
||||
const setRefs = useCallback(
|
||||
(next: LBPolicyRef[]) => {
|
||||
setPolicy((p: StoragePolicy) => ({
|
||||
...p,
|
||||
settings: { ...p.settings, lb_policies: next },
|
||||
}));
|
||||
},
|
||||
[setPolicy],
|
||||
);
|
||||
|
||||
const addChild = useCallback(() => {
|
||||
const used = new Set(refs.map((r) => r.policy));
|
||||
const candidate = policies.find((p) => !used.has(p.id));
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
setRefs([...refs, { policy: candidate.id, weight: 1 }]);
|
||||
}, [refs, policies, setRefs]);
|
||||
|
||||
const onChildChange = useCallback(
|
||||
(index: number, e: SelectChangeEvent<unknown>) => {
|
||||
const next = refs.slice();
|
||||
next[index] = { ...next[index], policy: e.target.value as number };
|
||||
setRefs(next);
|
||||
},
|
||||
[refs, setRefs],
|
||||
);
|
||||
|
||||
const onWeightChange = useCallback(
|
||||
(index: number, v: string) => {
|
||||
const w = parseInt(v);
|
||||
const next = refs.slice();
|
||||
next[index] = { ...next[index], weight: isNaN(w) ? undefined : Math.max(1, w) };
|
||||
setRefs(next);
|
||||
},
|
||||
[refs, setRefs],
|
||||
);
|
||||
|
||||
const removeChild = useCallback(
|
||||
(index: number) => {
|
||||
setRefs(refs.filter((_, i) => i !== index));
|
||||
},
|
||||
[refs, setRefs],
|
||||
);
|
||||
|
||||
if (!isLB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingSection>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("policy.loadBalance")}
|
||||
</Typography>
|
||||
<SettingSectionContent>
|
||||
<SettingForm title={t("policy.lbChildren")} lgWidth={12}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("policy.lbChildPolicy")}</TableCell>
|
||||
<TableCell width={140}>{t("policy.lbWeight")}</TableCell>
|
||||
<TableCell width={48} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{refs.map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<FormControl fullWidth size="small">
|
||||
<DenseSelect value={r.policy} onChange={(e) => onChildChange(i, e)}>
|
||||
{policies.map((p) => (
|
||||
<SquareMenuItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
disabled={refs.some((x, xi) => xi !== i && x.policy === p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</DenseSelect>
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DenseFilledTextField
|
||||
type="number"
|
||||
size="small"
|
||||
inputProps={{ min: 1 }}
|
||||
value={r.weight ?? 1}
|
||||
onChange={(e) => onWeightChange(i, e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={() => removeChild(i)}>
|
||||
<Dismiss fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<SecondaryButton onClick={addChild} disabled={refs.length >= policies.length}>
|
||||
{t("policy.lbAddChild")}
|
||||
</SecondaryButton>
|
||||
</Box>
|
||||
<NoMarginHelperText>{t("policy.lbChildrenDes")}</NoMarginHelperText>
|
||||
</SettingForm>
|
||||
</SettingSectionContent>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadBalanceSection;
|
||||
@ -0,0 +1,52 @@
|
||||
import { Button } from "@mui/material";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StoragePolicy } from "../../../../../api/dashboard";
|
||||
import { PolicyType } from "../../../../../api/explorer";
|
||||
import { DenseFilledTextField } from "../../../../Common/StyledComponents";
|
||||
import SettingForm from "../../../../Pages/Setting/SettingForm";
|
||||
import { NoMarginHelperText } from "../../../Settings/Settings";
|
||||
import { AddWizardProps } from "../../AddWizardDialog";
|
||||
|
||||
// LBWizard creates a bare load_balance policy; weighted children are bound on
|
||||
// the policy edit page.
|
||||
const LBWizard = ({ onSubmit }: AddWizardProps) => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [policy, setPolicy] = useState<StoragePolicy>({
|
||||
id: 0,
|
||||
name: "",
|
||||
type: PolicyType.load_balance,
|
||||
settings: {
|
||||
lb_policies: [],
|
||||
},
|
||||
edges: {},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.current?.checkValidity()) {
|
||||
formRef.current?.reportValidity();
|
||||
return;
|
||||
}
|
||||
onSubmit(policy);
|
||||
};
|
||||
|
||||
return (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<SettingForm title={t("policy.name")} lgWidth={12}>
|
||||
<DenseFilledTextField
|
||||
fullWidth
|
||||
required
|
||||
value={policy.name}
|
||||
onChange={(e) => setPolicy({ ...policy, name: e.target.value })}
|
||||
/>
|
||||
<NoMarginHelperText>{t("policy.lbWizardDes")}</NoMarginHelperText>
|
||||
</SettingForm>
|
||||
<Button variant="contained" color="primary" sx={{ mt: 2 }} onClick={handleSubmit}>
|
||||
{t("policy.create")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LBWizard;
|
||||
@ -0,0 +1,117 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
ListItemText,
|
||||
SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAllowedPolicies, relocateToPolicy, setPreferredPolicy } from "../../../api/api";
|
||||
import { Metadata, StoragePolicyBrief } from "../../../api/explorer";
|
||||
import { closeStoragePolicyDialog } from "../../../redux/globalStateSlice";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
||||
import { refreshFileList } from "../../../redux/thunks/filemanager";
|
||||
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar";
|
||||
import { DenseSelect } from "../../Common/StyledComponents";
|
||||
import { SquareMenuItem } from "../ContextMenu/ContextMenu";
|
||||
import { FileManagerIndex } from "../FileManager";
|
||||
|
||||
// StoragePolicyDialog switches the storage policy for either a folder's
|
||||
// preferred upload policy ("dir") or a file/folder's physical entities
|
||||
// ("relocate"), choosing from the group's allowed set.
|
||||
const StoragePolicyDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const open = useAppSelector((state) => state.globalState.storagePolicyDialogOpen);
|
||||
const mode = useAppSelector((state) => state.globalState.storagePolicyDialogMode);
|
||||
const file = useAppSelector((state) => state.globalState.storagePolicyDialogFile);
|
||||
|
||||
const [policies, setPolicies] = useState<StoragePolicyBrief[]>([]);
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDir = mode === "dir";
|
||||
const current = file?.metadata?.[Metadata.preferred_policy] ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setValue(isDir ? current : "");
|
||||
dispatch(getAllowedPolicies()).then((res) => setPolicies(res ?? []));
|
||||
}, [open]);
|
||||
|
||||
const onClose = () => dispatch(closeStoragePolicyDialog());
|
||||
|
||||
const onChange = (e: SelectChangeEvent<unknown>) => {
|
||||
setValue(e.target.value as string);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const uri = file.path;
|
||||
const req = isDir
|
||||
? dispatch(setPreferredPolicy({ uri, policy: value })).then(() => {
|
||||
enqueueSnackbar(t("fileManager.policySaved"), { variant: "success", action: DefaultCloseAction });
|
||||
})
|
||||
: dispatch(relocateToPolicy({ uri, policy: value })).then(() => {
|
||||
enqueueSnackbar(t("fileManager.relocateSubmitted"), { variant: "success", action: DefaultCloseAction });
|
||||
});
|
||||
req.then(() => {
|
||||
onClose();
|
||||
dispatch(refreshFileList(FileManagerIndex.main));
|
||||
}).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{isDir ? t("fileManager.dirPolicyTitle") : t("fileManager.relocateTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
{isDir
|
||||
? t("fileManager.dirPolicyDes", { name: file?.name ?? "" })
|
||||
: t("fileManager.relocateDes", { name: file?.name ?? "" })}
|
||||
</DialogContentText>
|
||||
<FormControl fullWidth>
|
||||
<DenseSelect value={value} onChange={onChange} disabled={loading}>
|
||||
{isDir && (
|
||||
<SquareMenuItem value="">
|
||||
<ListItemText primary={t("fileManager.policyInherit")} />
|
||||
</SquareMenuItem>
|
||||
)}
|
||||
{policies.map((p) => (
|
||||
<SquareMenuItem key={p.id} value={p.id}>
|
||||
<ListItemText
|
||||
primary={p.name}
|
||||
secondary={p.is_default ? t("fileManager.policyGroupDefault") : undefined}
|
||||
/>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</DenseSelect>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t("common:cancel")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={loading || (!isDir && !value) || (isDir && value === current)}
|
||||
>
|
||||
{isDir ? t("common:save") : t("fileManager.relocateStart")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoragePolicyDialog;
|
||||
@ -0,0 +1,130 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListByGroup(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
pc := NewStoragePolicyClient(client, nil)
|
||||
mk := func(name string, status storagepolicy.Status) *ent.StoragePolicy {
|
||||
return client.StoragePolicy.Create().
|
||||
SetName(name).SetType("local").SetStatus(status).SaveX(ctx)
|
||||
}
|
||||
|
||||
t.Run("legacy default included when no M2M rows", func(t *testing.T) {
|
||||
def := mk("def", storagepolicy.StatusActive)
|
||||
g := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).
|
||||
SetStoragePolicies(def).SaveX(ctx)
|
||||
|
||||
got, err := pc.ListByGroup(ctx, g)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, def.ID, got[0].ID)
|
||||
})
|
||||
|
||||
t.Run("allowed set union with default, active only, ordered by id", func(t *testing.T) {
|
||||
def := mk("def", storagepolicy.StatusActive)
|
||||
a := mk("a", storagepolicy.StatusActive)
|
||||
b := mk("b", storagepolicy.StatusActive)
|
||||
susp := mk("susp", storagepolicy.StatusSuspended)
|
||||
g := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).
|
||||
SetStoragePolicies(def).AddAllowedPolicies(a, b, susp).SaveX(ctx)
|
||||
|
||||
got, err := pc.ListByGroup(ctx, g)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
ids := []int{got[0].ID, got[1].ID, got[2].ID}
|
||||
require.IsIncreasing(t, ids)
|
||||
require.ElementsMatch(t, []int{a.ID, b.ID, def.ID}, ids)
|
||||
})
|
||||
|
||||
t.Run("suspended default not included", func(t *testing.T) {
|
||||
def := mk("def", storagepolicy.StatusSuspended)
|
||||
a := mk("a", storagepolicy.StatusActive)
|
||||
g := client.Group.Create().SetName("g3").SetPermissions(&boolset.BooleanSet{}).
|
||||
SetStoragePolicies(def).AddAllowedPolicies(a).SaveX(ctx)
|
||||
|
||||
got, err := pc.ListByGroup(ctx, g)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, a.ID, got[0].ID)
|
||||
})
|
||||
|
||||
t.Run("no usable policy returns empty", func(t *testing.T) {
|
||||
def := mk("def", storagepolicy.StatusSuspended)
|
||||
g := client.Group.Create().SetName("g4").SetPermissions(&boolset.BooleanSet{}).
|
||||
SetStoragePolicies(def).SaveX(ctx)
|
||||
|
||||
got, err := pc.ListByGroup(ctx, g)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveLoadBalance(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
pc := NewStoragePolicyClient(client, nil)
|
||||
|
||||
t.Run("skips suspended, missing and nested children", func(t *testing.T) {
|
||||
nested := client.StoragePolicy.Create().SetName("nested").SetType(types.PolicyTypeLoadBalance).
|
||||
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||
susp := client.StoragePolicy.Create().SetName("susp").SetType("local").
|
||||
SetStatus(storagepolicy.StatusSuspended).SaveX(ctx)
|
||||
ok := client.StoragePolicy.Create().SetName("ok").SetType("local").
|
||||
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
|
||||
SetStatus(storagepolicy.StatusActive).
|
||||
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
|
||||
{PolicyID: nested.ID, Weight: 10},
|
||||
{PolicyID: susp.ID, Weight: 10},
|
||||
{PolicyID: 99999, Weight: 10},
|
||||
{PolicyID: ok.ID, Weight: 10},
|
||||
}}).SaveX(ctx)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
got, err := pc.ResolveLoadBalance(ctx, lb)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ok.ID, got.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("weight of zero treated as one", func(t *testing.T) {
|
||||
zero := client.StoragePolicy.Create().SetName("zero").SetType("local").
|
||||
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
|
||||
SetStatus(storagepolicy.StatusActive).
|
||||
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
|
||||
{PolicyID: zero.ID, Weight: 0},
|
||||
}}).SaveX(ctx)
|
||||
|
||||
got, err := pc.ResolveLoadBalance(ctx, lb)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, zero.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("no usable children errors", func(t *testing.T) {
|
||||
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
|
||||
SetStatus(storagepolicy.StatusActive).
|
||||
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
|
||||
{PolicyID: 424242, Weight: 5},
|
||||
}}).SaveX(ctx)
|
||||
|
||||
_, err := pc.ResolveLoadBalance(ctx, lb)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package dbfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
entfile "github.com/cloudreve/Cloudreve/v4/ent/file"
|
||||
entmetadata "github.com/cloudreve/Cloudreve/v4/ent/metadata"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/schema"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// pickPolicyFixture builds an owner in a group whose allowed set is
|
||||
// [a, b] with `def` as the legacy default, and a dir chain root/dir/target.
|
||||
func pickPolicyFixture(t *testing.T, client *ent.Client) (*ent.Group, *ent.StoragePolicy, *ent.StoragePolicy, *ent.StoragePolicy, *ent.User, *ent.File, *ent.File) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
mk := func(name string) *ent.StoragePolicy {
|
||||
return client.StoragePolicy.Create().SetName(name).SetType("local").
|
||||
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||
}
|
||||
def := mk("def")
|
||||
a := mk("a")
|
||||
b := mk("b")
|
||||
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
|
||||
SetStoragePolicies(def).AddAllowedPolicies(a, b).SaveX(ctx)
|
||||
owner := client.User.Create().SetEmail("o@example.com").SetNick("o").
|
||||
SetGroup(group).SetSettings(&types.UserSetting{PreferredPolicy: b.ID}).SaveX(ctx)
|
||||
|
||||
root := client.File.Create().SetName(inventory.RootFolderName).
|
||||
SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
|
||||
dir := client.File.Create().SetName("dir").SetType(int(types.FileTypeFolder)).
|
||||
SetOwner(owner).SetParent(root).SaveX(ctx)
|
||||
return group, def, a, b, owner, root, dir
|
||||
}
|
||||
|
||||
func pickPolicyDBFS(t *testing.T, client *ent.Client, user *ent.User) *DBFS {
|
||||
t.Helper()
|
||||
hasher, err := hashid.New("pick-test-salt")
|
||||
require.NoError(t, err)
|
||||
return &DBFS{
|
||||
user: user,
|
||||
fileClient: inventory.NewFileClient(client, conf.SQLiteDB, hasher),
|
||||
storagePolicyClient: inventory.NewStoragePolicyClient(client, nil),
|
||||
hasher: hasher,
|
||||
l: logging.NewConsoleLogger(logging.LevelError),
|
||||
}
|
||||
}
|
||||
|
||||
func wrapChain(models ...*ent.File) *File {
|
||||
var parent *File
|
||||
for _, m := range models {
|
||||
parent = &File{Model: m, Parent: parent}
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
func setPreferredMarker(t *testing.T, client *ent.Client, f *ent.File, hasher hashid.Encoder, policyID int) {
|
||||
t.Helper()
|
||||
ctx := schema.SkipSoftDelete(context.Background())
|
||||
client.Metadata.Delete().Where(entmetadata.Name(MetadataPreferredPolicy),
|
||||
entmetadata.HasFileWith(entfile.ID(f.ID))).ExecX(ctx)
|
||||
client.Metadata.Create().SetFile(f).SetName(MetadataPreferredPolicy).
|
||||
SetValue(hashid.EncodePolicyID(hasher, policyID)).SaveX(ctx)
|
||||
}
|
||||
|
||||
// freshChain reloads the file rows so previously lazy-loaded metadata edges
|
||||
// do not leak between subtests.
|
||||
func freshChain(t *testing.T, client *ent.Client, ids ...int) *File {
|
||||
t.Helper()
|
||||
models := make([]*ent.File, len(ids))
|
||||
for i, id := range ids {
|
||||
models[i] = client.File.GetX(context.Background(), id)
|
||||
}
|
||||
return wrapChain(models...)
|
||||
}
|
||||
|
||||
func TestPickPolicyPrecedence(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
group, def, a, b, owner, root, dir := pickPolicyFixture(t, client)
|
||||
owner.SetGroup(group)
|
||||
allowed := []*ent.StoragePolicy{a, b, def}
|
||||
|
||||
f := pickPolicyDBFS(t, client, owner)
|
||||
|
||||
t.Run("directory marker wins over user preference", func(t *testing.T) {
|
||||
setPreferredMarker(t, client, dir, f.hasher, a.ID)
|
||||
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||
require.Equal(t, a.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("user preference wins over group default", func(t *testing.T) {
|
||||
client.Metadata.Delete().ExecX(schema.SkipSoftDelete(ctx))
|
||||
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||
require.Equal(t, b.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("invalid marker stops ancestor inheritance", func(t *testing.T) {
|
||||
// Root carries a valid marker, but the nearer dir marker points at a
|
||||
// policy outside the allowed set — must not fall through to root's.
|
||||
outside := client.StoragePolicy.Create().SetName("outside").SetType("local").
|
||||
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||
setPreferredMarker(t, client, root, f.hasher, a.ID)
|
||||
setPreferredMarker(t, client, dir, f.hasher, outside.ID)
|
||||
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||
require.Equal(t, b.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("other user's tree ignores user preference", func(t *testing.T) {
|
||||
other := client.User.Create().SetEmail("x@example.com").SetNick("x").SetGroup(group).SaveX(ctx)
|
||||
otherFs := pickPolicyDBFS(t, client, other)
|
||||
got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||
require.Equal(t, def.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("group default applies without preferences", func(t *testing.T) {
|
||||
ownerNoPref := client.User.Create().SetEmail("np@example.com").SetNick("np").
|
||||
SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx)
|
||||
ownerNoPref.SetGroup(group)
|
||||
fsNoPref := pickPolicyDBFS(t, client, ownerNoPref)
|
||||
got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed)
|
||||
require.Equal(t, def.ID, got.ID)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,238 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/workflows"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// StoragePolicyBrief is the user-facing view of an allowed storage policy.
|
||||
type StoragePolicyBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsDefault bool `json:"is_default,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
// AllowedPolicyService lists the storage policies the caller's group may use.
|
||||
AllowedPolicyService struct{}
|
||||
AllowedPolicyParamCtx struct{}
|
||||
|
||||
// PreferredPolicyService sets or clears a directory's preferred storage
|
||||
// policy. New uploads inside the directory use it.
|
||||
PreferredPolicyService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
Policy string `json:"policy"`
|
||||
}
|
||||
PreferredPolicyParamCtx struct{}
|
||||
|
||||
// FileRelocateService moves a file's or folder's entities to another
|
||||
// storage policy via the resumable relocation task.
|
||||
FileRelocateService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
Policy string `json:"policy" binding:"required"`
|
||||
}
|
||||
FileRelocateParamCtx struct{}
|
||||
|
||||
// FileRelocateResponse carries the created relocation task id.
|
||||
FileRelocateResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
)
|
||||
|
||||
// allowedGroupPolicies resolves the group's usable policy set and maps each
|
||||
// to a brief. The group's configured default is marked.
|
||||
func allowedGroupPolicies(c *gin.Context, group *ent.Group, dep dependency.Dep) ([]*ent.StoragePolicy, []*StoragePolicyBrief, error) {
|
||||
allowed, err := dep.StoragePolicyClient().ListByGroup(c, group)
|
||||
if err != nil {
|
||||
return nil, nil, serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil, nil, serializer.NewError(serializer.CodeNoPermissionErr, "No storage policy is available for your group", nil)
|
||||
}
|
||||
|
||||
hasher := dep.HashIDEncoder()
|
||||
briefs := lo.Map(allowed, func(p *ent.StoragePolicy, _ int) *StoragePolicyBrief {
|
||||
return &StoragePolicyBrief{
|
||||
ID: hashid.EncodePolicyID(hasher, p.ID),
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
IsDefault: p.ID == group.StoragePolicyID,
|
||||
}
|
||||
})
|
||||
return allowed, briefs, nil
|
||||
}
|
||||
|
||||
func (s *AllowedPolicyService) Get(c *gin.Context) ([]*StoragePolicyBrief, error) {
|
||||
user := inventory.UserFromContext(c)
|
||||
if user.Edges.Group == nil {
|
||||
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Group not loaded", nil)
|
||||
}
|
||||
_, briefs, err := allowedGroupPolicies(c, user.Edges.Group, dependency.FromContext(c))
|
||||
return briefs, err
|
||||
}
|
||||
|
||||
// decodeAllowedPolicy validates a hashid-encoded policy id against the
|
||||
// group's allowed set and returns the matching policy.
|
||||
func decodeAllowedPolicy(c *gin.Context, dep dependency.Dep, group *ent.Group, raw string) (*ent.StoragePolicy, error) {
|
||||
id, err := dep.HashIDEncoder().Decode(raw, hashid.PolicyID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid storage policy", err)
|
||||
}
|
||||
|
||||
allowed, err := dep.StoragePolicyClient().ListByGroup(c, group)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
|
||||
}
|
||||
for _, p := range allowed {
|
||||
if p.ID == id {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Storage policy is not available for your group", nil)
|
||||
}
|
||||
|
||||
// ownedPolicyFile resolves the target and enforces ownership, returning the
|
||||
// fs file for metadata access.
|
||||
func ownedPolicyFile(c *gin.Context, dep dependency.Dep, uriRaw string) (*dbfs.File, error) {
|
||||
user := inventory.UserFromContext(c)
|
||||
uri, err := fs.NewUriFromString(uriRaw)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
f, err := m.Get(c, uri, dbfs.WithFileEntities())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
file, ok := f.(*dbfs.File)
|
||||
if !ok {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Unsupported file system", nil)
|
||||
}
|
||||
if file.OwnerID() != user.ID {
|
||||
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Only the owner can manage storage policies", nil)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (s *PreferredPolicyService) Update(c *gin.Context) (*StoragePolicyBrief, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
|
||||
file, err := ownedPolicyFile(c, dep, s.Uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.Type() != types.FileTypeFolder {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Preferred storage policy applies to folders only", nil)
|
||||
}
|
||||
|
||||
var policy *ent.StoragePolicy
|
||||
if s.Policy != "" {
|
||||
policy, err = decodeAllowedPolicy(c, dep, user.Edges.Group, s.Policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
fc := dep.FileClient()
|
||||
if policy == nil {
|
||||
err = fc.RemoveMetadata(c, file.Model, dbfs.MetadataPreferredPolicy)
|
||||
} else {
|
||||
err = fc.UpsertMetadata(c, file.Model, map[string]string{
|
||||
dbfs.MetadataPreferredPolicy: hashid.EncodePolicyID(dep.HashIDEncoder(), policy.ID),
|
||||
}, map[string]bool{dbfs.MetadataPreferredPolicy: true})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update metadata", err)
|
||||
}
|
||||
|
||||
if policy == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &StoragePolicyBrief{
|
||||
ID: hashid.EncodePolicyID(dep.HashIDEncoder(), policy.ID),
|
||||
Name: policy.Name,
|
||||
Type: policy.Type,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileRelocateService) Create(c *gin.Context) (*FileRelocateResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
|
||||
policy, err := decodeAllowedPolicy(c, dep, user.Edges.Group, s.Policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A load-balance target resolves to one concrete child at enqueue time.
|
||||
if policy.Type == types.PolicyTypeLoadBalance {
|
||||
policy, err = dep.StoragePolicyClient().ResolveLoadBalance(c, policy)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to resolve load-balanced storage policy", err)
|
||||
}
|
||||
}
|
||||
|
||||
file, err := ownedPolicyFile(c, dep, s.Uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Depth is a countdown in the walk implementation; a large value walks
|
||||
// the whole subtree. Entities already on the destination are skipped by
|
||||
// the task, so they are filtered out of the request entirely.
|
||||
pending := lo.Filter(file.Model.Edges.Entities, func(e *ent.Entity, _ int) bool {
|
||||
return e.StoragePolicyEntities != policy.ID
|
||||
})
|
||||
entityIDs := lo.Map(pending, func(e *ent.Entity, _ int) int { return e.ID })
|
||||
if file.Type() == types.FileTypeFolder {
|
||||
uri, _ := fs.NewUriFromString(s.Uri)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
err = m.Walk(c, uri, 1<<30, func(f fs.File, _ int) error {
|
||||
dbFile, ok := f.(*dbfs.File)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, e := range dbFile.Model.Edges.Entities {
|
||||
if e.StoragePolicyEntities != policy.ID {
|
||||
entityIDs = append(entityIDs, e.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, dbfs.WithFileEntities())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk folder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
entityIDs = lo.Uniq(entityIDs)
|
||||
if len(entityIDs) == 0 {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Nothing to relocate", nil)
|
||||
}
|
||||
|
||||
t, err := workflows.NewRelocateTask(c, entityIDs, policy.ID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create relocation task", err)
|
||||
}
|
||||
if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
|
||||
return nil, fmt.Errorf("failed to submit task: %w", err)
|
||||
}
|
||||
|
||||
return &FileRelocateResponse{ID: hashid.EncodeTaskID(dep.HashIDEncoder(), t.ID())}, nil
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// policyDepStub exposes only the dependencies decodeAllowedPolicy touches.
|
||||
type policyDepStub struct {
|
||||
dependency.Dep
|
||||
policyClient inventory.StoragePolicyClient
|
||||
hasher hashid.Encoder
|
||||
}
|
||||
|
||||
func (d *policyDepStub) StoragePolicyClient() inventory.StoragePolicyClient { return d.policyClient }
|
||||
func (d *policyDepStub) HashIDEncoder() hashid.Encoder { return d.hasher }
|
||||
|
||||
func TestDecodeAllowedPolicy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
mk := func(name string) *ent.StoragePolicy {
|
||||
return client.StoragePolicy.Create().SetName(name).SetType("local").
|
||||
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||
}
|
||||
def := mk("def")
|
||||
a := mk("a")
|
||||
outside := mk("outside")
|
||||
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
|
||||
SetStoragePolicies(def).AddAllowedPolicies(a).SaveX(ctx)
|
||||
|
||||
hasher, err := hashid.New("policy-test-salt")
|
||||
require.NoError(t, err)
|
||||
dep := &policyDepStub{policyClient: inventory.NewStoragePolicyClient(client, nil), hasher: hasher}
|
||||
|
||||
engine := gin.New()
|
||||
engine.ContextWithFallback = true
|
||||
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
util.WithValue(c, dependency.DepCtx{}, dep)
|
||||
|
||||
t.Run("allowed policy decodes", func(t *testing.T) {
|
||||
got, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, a.ID))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, a.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("legacy default policy decodes", func(t *testing.T) {
|
||||
got, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, def.ID))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, def.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("policy outside the group set is rejected", func(t *testing.T) {
|
||||
_, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, outside.ID))
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid hashid is rejected", func(t *testing.T) {
|
||||
_, err := decodeAllowedPolicy(c, dep, group, "!!!")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in new issue