fix(permissions): enforce workspace roles across backend and UI
Viewers could mutate posts, automations and trigger AI write endpoints,
and every role saw create/manage affordances that 403'd on click.
Backend (security):
- PostPolicy update/delete now require member+ (was tenancy-only), which
also gates the AI write endpoints that authorize('update')
- AutomationPolicy create/update/delete require member+; activate/pause
delegate to update
- AutomationController authorizes index/store/show; AnalyticsController
authorizes view
- Comments stay open to members incl. viewer (by design)
Frontend (UI gating via new useWorkspaceRole composable):
- Sidebar: create post / create workspace / automations / library nav
- Accounts grid: connect / disconnect / reconnect (admin+)
- Members: invite / change role / remove / cancel invite (admin+)
- Account billing tab (owner); posts index + calendar create affordances
Tests: PostPolicyTest, AutomationPolicyTest (all four roles) and an
end-to-end WorkspaceRolePermissionsTest; aligned the automation test
suites' account/workspace setup with role pivots.
This commit is contained in:
parent
027560452b
commit
5bb39da598
21 changed files with 354 additions and 85 deletions
|
|
@ -42,6 +42,8 @@ public function index(Request $request): Response
|
|||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$accounts = $workspace->socialAccounts()
|
||||
->where('is_active', true)
|
||||
->whereIn('platform', self::SUPPORTED_PLATFORMS)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ class AutomationController extends Controller
|
|||
{
|
||||
public function index(ListAutomations $list): Response
|
||||
{
|
||||
$this->authorize('viewAny', Automation::class);
|
||||
|
||||
$workspace = request()->user()->currentWorkspace;
|
||||
|
||||
$automations = Inertia::scroll(fn () => AutomationResource::collection(
|
||||
|
|
@ -61,6 +63,8 @@ public function index(ListAutomations $list): Response
|
|||
|
||||
public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', Automation::class);
|
||||
|
||||
$automation = $create(
|
||||
$request->user()->currentWorkspace,
|
||||
$request->user(),
|
||||
|
|
@ -71,6 +75,8 @@ public function store(StoreAutomationRequest $request, CreateAutomation $create)
|
|||
|
||||
public function show(Automation $automation): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $automation);
|
||||
|
||||
return redirect()->route('app.automations.workflow', $automation->id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,17 +21,20 @@ public function view(User $user, Automation $automation): bool
|
|||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->currentWorkspace !== null;
|
||||
return $user->currentWorkspace !== null
|
||||
&& $user->can('createPost', $user->currentWorkspace);
|
||||
}
|
||||
|
||||
public function update(User $user, Automation $automation): bool
|
||||
{
|
||||
return $automation->workspace_id === $user->current_workspace_id;
|
||||
return $automation->workspace_id === $user->current_workspace_id
|
||||
&& $user->can('createPost', $user->currentWorkspace);
|
||||
}
|
||||
|
||||
public function delete(User $user, Automation $automation): bool
|
||||
{
|
||||
return $automation->workspace_id === $user->current_workspace_id;
|
||||
return $automation->workspace_id === $user->current_workspace_id
|
||||
&& $user->can('createPost', $user->currentWorkspace);
|
||||
}
|
||||
|
||||
public function activate(User $user, Automation $automation): bool
|
||||
|
|
|
|||
|
|
@ -33,11 +33,12 @@ public function update(User $user, Post $post): bool|Response
|
|||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
return $user->can('createPost', $user->currentWorkspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize deleting a post. Same workspace-tenancy guard as `view`.
|
||||
* Authorize deleting a post: tenancy guard (404 across tenants) then the
|
||||
* same role gate as `update` — viewers are read-only (403).
|
||||
*/
|
||||
public function delete(User $user, Post $post): bool|Response
|
||||
{
|
||||
|
|
@ -45,7 +46,7 @@ public function delete(User $user, Post $post): bool|Response
|
|||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
return $user->can('createPost', $user->currentWorkspace);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useActiveUrl } from '@/composables/useActiveUrl';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
|
||||
import { index as assets } from '@/routes/app/assets';
|
||||
import { index as automations } from '@/routes/app/automations';
|
||||
|
|
@ -63,6 +64,8 @@ const currentWorkspace = computed<Workspace | null>(() => page.props.auth.curren
|
|||
const workspaces = computed<Workspace[]>(() => page.props.auth.workspaces as Workspace[]);
|
||||
const subscriptionPastDue = computed<boolean>(() => Boolean(page.props.auth.subscriptionPastDue));
|
||||
|
||||
const { canCreatePost, canManageAutomations, canCreateWorkspace } = useWorkspaceRole();
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: trans('sidebar.posts.calendar'),
|
||||
|
|
@ -74,12 +77,16 @@ const mainNavItems = computed<NavItem[]>(() => [
|
|||
href: analytics.url(),
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.automations'),
|
||||
href: automations.url(),
|
||||
icon: IconBolt,
|
||||
badge: 'Beta',
|
||||
},
|
||||
...(canManageAutomations.value
|
||||
? [
|
||||
{
|
||||
title: trans('sidebar.automations'),
|
||||
href: automations.url(),
|
||||
icon: IconBolt,
|
||||
badge: 'Beta',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const postsNavItems = computed<NavItem[]>(() => [
|
||||
|
|
@ -112,21 +119,26 @@ const workspaceNavItems = computed<NavItem[]>(() => [
|
|||
href: accounts.url(),
|
||||
icon: IconAffiliate,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.signatures'),
|
||||
href: signatures.url(),
|
||||
icon: IconHash,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.labels'),
|
||||
href: labels.url(),
|
||||
icon: IconTag,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.assets'),
|
||||
href: assets.url(),
|
||||
icon: IconPhoto,
|
||||
},
|
||||
// Signatures / labels / assets are content-authoring tools (member+ only).
|
||||
...(canCreatePost.value
|
||||
? [
|
||||
{
|
||||
title: trans('sidebar.workspace.signatures'),
|
||||
href: signatures.url(),
|
||||
icon: IconHash,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.labels'),
|
||||
href: labels.url(),
|
||||
icon: IconTag,
|
||||
},
|
||||
{
|
||||
title: trans('sidebar.workspace.assets'),
|
||||
href: assets.url(),
|
||||
icon: IconPhoto,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const switchWorkspace = (workspaceId: string) => {
|
||||
|
|
@ -176,11 +188,13 @@ const handleCreateWorkspace = () => {
|
|||
{{ workspace.name }}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleCreateWorkspace">
|
||||
<IconPlus class="size-4" />
|
||||
{{ $t('sidebar.create_workspace') }}
|
||||
</DropdownMenuItem>
|
||||
<template v-if="canCreateWorkspace">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleCreateWorkspace">
|
||||
<IconPlus class="size-4" />
|
||||
{{ $t('sidebar.create_workspace') }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
|
@ -189,7 +203,7 @@ const handleCreateWorkspace = () => {
|
|||
|
||||
<SidebarContent>
|
||||
<!-- Create Post Button -->
|
||||
<div v-if="currentWorkspace" class="px-2 py-2">
|
||||
<div v-if="currentWorkspace && canCreatePost" class="px-2 py-2">
|
||||
<Link :href="createPost.url()" class="block">
|
||||
<Button class="w-full">
|
||||
{{ $t('sidebar.create_post') }}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.v
|
|||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useOAuthPopup } from '@/composables/useOAuthPopup';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import { disconnect } from '@/routes/app/accounts';
|
||||
import { Platform } from '@/types/platform';
|
||||
|
||||
|
|
@ -163,6 +164,8 @@ const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(
|
|||
null,
|
||||
);
|
||||
|
||||
const { canManageAccounts } = useWorkspaceRole();
|
||||
|
||||
const { openOAuthPopup } = useOAuthPopup(() => {
|
||||
router.reload();
|
||||
});
|
||||
|
|
@ -306,31 +309,33 @@ const cardState = computed((): Record<string, CardStateValue> => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="cardState[platform.value] === CardState.Reconnect"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="reconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.reconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="cardState[platform.value] === CardState.Connected"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="disconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.disconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="connectPlatform(platform.value)"
|
||||
>
|
||||
{{ $t('accounts.connect_cta') }}
|
||||
</Button>
|
||||
<template v-if="canManageAccounts">
|
||||
<Button
|
||||
v-if="cardState[platform.value] === CardState.Reconnect"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="reconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.reconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="cardState[platform.value] === CardState.Connected"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="disconnectAccount(cardConnection[platform.value]!)"
|
||||
>
|
||||
{{ $t('accounts.disconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="connectPlatform(platform.value)"
|
||||
>
|
||||
{{ $t('accounts.connect_cta') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import { destroy as destroyInvite } from '@/routes/app/invites';
|
||||
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
|
||||
import { WorkspaceRole } from '@/types/workspace-role';
|
||||
|
|
@ -65,6 +66,8 @@ const roleIcon = (role: string) => {
|
|||
const page = usePage();
|
||||
const currentUserId = computed(() => page.props.auth.user.id);
|
||||
|
||||
const { canManageTeam } = useWorkspaceRole();
|
||||
|
||||
const inviteDialogOpen = ref(false);
|
||||
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
|
@ -86,7 +89,7 @@ const changeRole = (member: Member, role: string) => {
|
|||
:description="$t('settings.workspace.members_description')"
|
||||
/>
|
||||
|
||||
<Button @click="handleInviteClick">
|
||||
<Button v-if="canManageTeam" @click="handleInviteClick">
|
||||
{{ $t('settings.members.invite.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -110,7 +113,7 @@ const changeRole = (member: Member, role: string) => {
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu v-if="member.id !== currentUserId">
|
||||
<DropdownMenu v-if="canManageTeam && member.id !== currentUserId">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="size-8">
|
||||
<IconDots class="size-4" />
|
||||
|
|
@ -153,6 +156,7 @@ const changeRole = (member: Member, role: string) => {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
v-if="canManageTeam"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8 bg-rose-100 hover:bg-rose-200"
|
||||
|
|
|
|||
42
resources/js/composables/useWorkspaceRole.ts
Normal file
42
resources/js/composables/useWorkspaceRole.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { WorkspaceRole } from '@/types/workspace-role';
|
||||
|
||||
/**
|
||||
* Role-based capability flags for the current workspace, mirroring the
|
||||
* backend `WorkspacePolicy` so UI affordances only render for roles that can
|
||||
* actually perform the action. Reads `auth.currentWorkspace.role`.
|
||||
*/
|
||||
export const useWorkspaceRole = () => {
|
||||
const page = usePage();
|
||||
|
||||
const role = computed<string | null>(
|
||||
() => (page.props.auth?.currentWorkspace?.role as string | null) ?? null,
|
||||
);
|
||||
|
||||
const isOwner = computed(() => role.value === WorkspaceRole.Owner);
|
||||
const isAdminOrAbove = computed(
|
||||
() => isOwner.value || role.value === WorkspaceRole.Admin,
|
||||
);
|
||||
const isMemberOrAbove = computed(
|
||||
() => isAdminOrAbove.value || role.value === WorkspaceRole.Member,
|
||||
);
|
||||
|
||||
return {
|
||||
role,
|
||||
isOwner,
|
||||
isAdminOrAbove,
|
||||
isMemberOrAbove,
|
||||
// member+ (owner/admin/member) — content authoring
|
||||
canCreatePost: isMemberOrAbove,
|
||||
canManageAutomations: isMemberOrAbove,
|
||||
// admin+ (owner/admin) — workspace administration
|
||||
canManageAccounts: isAdminOrAbove,
|
||||
canManageTeam: isAdminOrAbove,
|
||||
canManageWorkspace: isAdminOrAbove,
|
||||
// owner only
|
||||
canManageBilling: isOwner,
|
||||
canCreateWorkspace: isOwner,
|
||||
};
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import date from '@/date';
|
||||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
|
@ -53,6 +54,8 @@ const props = defineProps<Props>();
|
|||
|
||||
// Mobile detection
|
||||
const isMobile = ref(false);
|
||||
const { canCreatePost } = useWorkspaceRole();
|
||||
|
||||
const createPostUrl = (isoDate: string | null = null) =>
|
||||
isoDate ? createPost.url({ query: { date: isoDate } }) : createPost.url();
|
||||
const checkMobile = () => {
|
||||
|
|
@ -277,7 +280,7 @@ const formatTime = (scheduledAt: string): string => {
|
|||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Link :href="createPost.url()">
|
||||
<Link v-if="canCreatePost" :href="createPost.url()">
|
||||
<Button>{{ $t('calendar.new_post') }}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -374,6 +377,7 @@ const formatTime = (scheduledAt: string): string => {
|
|||
<div class="flex-1 space-y-2 overflow-y-auto p-2">
|
||||
<!-- Add Post Button -->
|
||||
<Link
|
||||
v-if="canCreatePost"
|
||||
:href="createPostUrl(day.format('YYYY-MM-DD'))"
|
||||
class="flex w-full items-center justify-center rounded-md border-2 border-dashed border-foreground/25 p-2 text-foreground/60 transition-colors hover:border-foreground hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
|
|
@ -471,6 +475,7 @@ const formatTime = (scheduledAt: string): string => {
|
|||
{{ day.format('D') }}
|
||||
</span>
|
||||
<Link
|
||||
v-if="canCreatePost"
|
||||
:href="createPostUrl(day.format('YYYY-MM-DD'))"
|
||||
class="inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:rotate-90 hover:bg-violet-100 focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||
import { useWorkspaceEcho } from '@/composables/echo/useWorkspaceEcho';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { getPostStatusConfig } from '@/composables/usePostStatus';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
|
@ -147,6 +148,8 @@ const postUrl = (post: Post): string =>
|
|||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const { canCreatePost } = useWorkspaceRole();
|
||||
|
||||
const handleDelete = (post: Post) => {
|
||||
deleteModal.value?.open({
|
||||
url: destroyPost.url(post.id),
|
||||
|
|
@ -194,7 +197,7 @@ useWorkspaceEcho(
|
|||
<LabelFilter v-if="labels.length" v-model="selectedLabelIds" :labels="labels" />
|
||||
</div>
|
||||
|
||||
<Link :href="createPost.url()">
|
||||
<Link v-if="canCreatePost" :href="createPost.url()">
|
||||
<Button>{{ $t('posts.new_post') }}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -297,7 +300,7 @@ useWorkspaceEcho(
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="handleDuplicate(post)">
|
||||
<DropdownMenuItem v-if="canCreatePost" @click="handleDuplicate(post)">
|
||||
<IconCopyPlus class="size-4" />
|
||||
{{ $t('posts.actions.duplicate') }}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -305,7 +308,7 @@ useWorkspaceEcho(
|
|||
<IconCopy class="size-4" />
|
||||
{{ $t('posts.actions.copy_id') }}
|
||||
</DropdownMenuItem>
|
||||
<template v-if="canDelete(post)">
|
||||
<template v-if="canCreatePost && canDelete(post)">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { edit as accountEdit, update as accountUpdate } from '@/routes/app/account';
|
||||
import { index as billingIndex } from '@/routes/app/billing';
|
||||
|
|
@ -26,10 +27,14 @@ defineProps<{
|
|||
selfHosted: boolean;
|
||||
}>();
|
||||
|
||||
const { canManageBilling } = useWorkspaceRole();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ name: 'account', label: trans('settings.account.tabs.account'), href: accountEdit().url },
|
||||
{ name: 'usage', label: trans('settings.account.tabs.usage'), href: usageIndex().url },
|
||||
{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url },
|
||||
...(canManageBilling.value
|
||||
? [{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url }]
|
||||
: []),
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
$this->automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@
|
|||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@
|
|||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@
|
|||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
|
|
|||
62
tests/Feature/Permissions/WorkspaceRolePermissionsTest.php
Normal file
62
tests/Feature/Permissions/WorkspaceRolePermissionsTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->owner = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'account_id' => $this->owner->account_id,
|
||||
'user_id' => $this->owner->id,
|
||||
]);
|
||||
$this->owner->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->viewer = User::factory()->create([
|
||||
'account_id' => $this->owner->account_id,
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->viewer->id, ['role' => Role::Viewer->value]);
|
||||
|
||||
$this->member = User::factory()->create([
|
||||
'account_id' => $this->owner->account_id,
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->member->id, ['role' => Role::Member->value]);
|
||||
|
||||
$this->post = Post::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('a viewer cannot delete a post', function () {
|
||||
$this->actingAs($this->viewer)
|
||||
->delete(route('app.posts.destroy', $this->post))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertDatabaseHas('posts', ['id' => $this->post->id]);
|
||||
});
|
||||
|
||||
test('a viewer cannot create an automation', function () {
|
||||
$this->actingAs($this->viewer)
|
||||
->post(route('app.automations.store'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('a member can create an automation', function () {
|
||||
$this->actingAs($this->member)
|
||||
->post(route('app.automations.store'))
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
test('a viewer can comment on a post', function () {
|
||||
$this->actingAs($this->viewer)
|
||||
->postJson(route('app.posts.comments.store', $this->post), ['body' => 'Looks good!'])
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('post_comments', [
|
||||
'post_id' => $this->post->id,
|
||||
'user_id' => $this->viewer->id,
|
||||
]);
|
||||
});
|
||||
60
tests/Unit/Policies/AutomationPolicyTest.php
Normal file
60
tests/Unit/Policies/AutomationPolicyTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Automation;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Policies\AutomationPolicy;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->policy = new AutomationPolicy;
|
||||
});
|
||||
|
||||
/**
|
||||
* Build an automation + an actor with the given workspace role, both in one account.
|
||||
*
|
||||
* @return array{0: User, 1: Automation}
|
||||
*/
|
||||
function automationPolicyActor(string $role): array
|
||||
{
|
||||
$account = Account::factory()->create();
|
||||
$owner = User::factory()->create(['account_id' => $account->id]);
|
||||
$account->update(['owner_id' => $owner->id]);
|
||||
$workspace = Workspace::factory()->create(['account_id' => $account->id, 'user_id' => $owner->id]);
|
||||
$automation = Automation::factory()->create(['workspace_id' => $workspace->id]);
|
||||
|
||||
if ($role === 'owner') {
|
||||
$actor = $owner;
|
||||
} else {
|
||||
$actor = User::factory()->create(['account_id' => $account->id]);
|
||||
$workspace->members()->attach($actor->id, ['role' => $role]);
|
||||
}
|
||||
|
||||
$actor->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$actor->refresh(), $automation];
|
||||
}
|
||||
|
||||
test('any workspace member (including viewer) can view automations', function (string $role) {
|
||||
[$actor, $automation] = automationPolicyActor($role);
|
||||
|
||||
expect($this->policy->viewAny($actor))->toBeTrue()
|
||||
->and($this->policy->view($actor, $automation))->toBeTrue();
|
||||
})->with(['owner', 'admin', 'member', 'viewer']);
|
||||
|
||||
test('automation create/update/delete/activate/pause is allowed for member+ and denied for viewer', function (string $role, bool $allowed) {
|
||||
[$actor, $automation] = automationPolicyActor($role);
|
||||
|
||||
expect($this->policy->create($actor))->toBe($allowed)
|
||||
->and($this->policy->update($actor, $automation))->toBe($allowed)
|
||||
->and($this->policy->delete($actor, $automation))->toBe($allowed)
|
||||
->and($this->policy->activate($actor, $automation))->toBe($allowed)
|
||||
->and($this->policy->pause($actor, $automation))->toBe($allowed);
|
||||
})->with([
|
||||
'owner' => ['owner', true],
|
||||
'admin' => ['admin', true],
|
||||
'member' => ['member', true],
|
||||
'viewer' => ['viewer', false],
|
||||
]);
|
||||
56
tests/Unit/Policies/PostPolicyTest.php
Normal file
56
tests/Unit/Policies/PostPolicyTest.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Policies\PostPolicy;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->policy = new PostPolicy;
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a post + an actor with the given workspace role, both in one account.
|
||||
*
|
||||
* @return array{0: User, 1: Post}
|
||||
*/
|
||||
function postPolicyActor(string $role): array
|
||||
{
|
||||
$account = Account::factory()->create();
|
||||
$owner = User::factory()->create(['account_id' => $account->id]);
|
||||
$account->update(['owner_id' => $owner->id]);
|
||||
$workspace = Workspace::factory()->create(['account_id' => $account->id, 'user_id' => $owner->id]);
|
||||
$post = Post::factory()->create(['workspace_id' => $workspace->id]);
|
||||
|
||||
if ($role === 'owner') {
|
||||
$actor = $owner;
|
||||
} else {
|
||||
$actor = User::factory()->create(['account_id' => $account->id]);
|
||||
$workspace->members()->attach($actor->id, ['role' => $role]);
|
||||
}
|
||||
|
||||
$actor->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$actor->refresh(), $post];
|
||||
}
|
||||
|
||||
test('any workspace member (including viewer) can view a post', function (string $role) {
|
||||
[$actor, $post] = postPolicyActor($role);
|
||||
|
||||
expect($this->policy->view($actor, $post))->toBeTrue();
|
||||
})->with(['owner', 'admin', 'member', 'viewer']);
|
||||
|
||||
test('post update/delete is allowed for member+ and denied for viewer', function (string $role, bool $allowed) {
|
||||
[$actor, $post] = postPolicyActor($role);
|
||||
|
||||
expect($this->policy->update($actor, $post))->toBe($allowed);
|
||||
expect($this->policy->delete($actor, $post))->toBe($allowed);
|
||||
})->with([
|
||||
'owner' => ['owner', true],
|
||||
'admin' => ['admin', true],
|
||||
'member' => ['member', true],
|
||||
'viewer' => ['viewer', false],
|
||||
]);
|
||||
Loading…
Reference in a new issue