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:
Paulo Castellano 2026-06-22 16:31:54 -03:00
parent 027560452b
commit 5bb39da598
21 changed files with 354 additions and 85 deletions

View file

@ -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)

View file

@ -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);
}

View file

@ -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

View file

@ -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);
}
/**

View file

@ -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') }}

View file

@ -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>

View file

@ -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"

View 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,
};
};

View file

@ -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"
>

View file

@ -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"

View file

@ -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>

View file

@ -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();
});

View file

@ -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();

View file

@ -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]);
});

View file

@ -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();
});

View file

@ -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();
});

View file

@ -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();
});

View file

@ -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();
});

View 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,
]);
});

View 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],
]);

View 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],
]);