trypost/tests/Feature/WorkspaceInviteControllerTest.php

434 lines
15 KiB
PHP
Raw Permalink Normal View History

2026-01-18 23:33:45 +00:00
<?php
declare(strict_types=1);
2026-01-18 23:33:45 +00:00
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
use App\Models\AccessToken;
use App\Models\Account;
use App\Models\Invite;
2026-01-18 23:33:45 +00:00
use App\Models\User;
use App\Models\Workspace;
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
use Illuminate\Support\Facades\DB;
2026-01-18 23:33:45 +00:00
use Illuminate\Support\Facades\Mail;
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
use Illuminate\Support\Str;
2026-01-18 23:33:45 +00:00
beforeEach(function () {
Mail::fake();
config(['trypost.self_hosted' => true]);
$this->account = Account::factory()->create();
$this->user = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => WorkspaceRole::Admin->value]);
2026-01-18 23:33:45 +00:00
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Index tests
test('members index requires authentication', function () {
$response = $this->get(route('app.members'));
2026-01-18 23:33:45 +00:00
$response->assertRedirect(route('login'));
});
test('members page shows members and invites', function () {
Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->user->id,
'workspaces' => [$this->workspace->id],
2026-01-18 23:33:45 +00:00
]);
$response = $this->actingAs($this->user)->get(route('app.members'));
2026-01-18 23:33:45 +00:00
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/workspace/Members', false)
2026-01-18 23:33:45 +00:00
->has('workspace')
->has('members')
->has('invites')
->has('owner')
->has('roles')
2026-01-18 23:33:45 +00:00
);
});
// Store invite tests
test('store invite requires authentication', function () {
$response = $this->post(route('app.invites.store'), [
2026-01-18 23:33:45 +00:00
'email' => 'test@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertRedirect(route('login'));
});
test('store invite creates invite and sends email', function () {
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
2026-01-18 23:33:45 +00:00
'email' => 'newmember@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertRedirect();
$this->assertDatabaseHas('invites', [
'account_id' => $this->account->id,
2026-01-18 23:33:45 +00:00
'email' => 'newmember@example.com',
]);
Mail::assertQueued(WorkspaceInviteMail::class);
});
Allow account owners to delete workspaces (#208) * Allow owners and admins to delete workspaces from settings. Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant canDelete prop from workspace settings. The settings page is already gated by update (owner/admin), which matches delete. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract workspace delete danger zone into DeleteWorkspace component. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify workspace delete billing copy across locales. Co-authored-by: Cursor <cursoragent@cursor.com> * Match workspace delete card to the delete-account settings pattern. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden workspace and account deletion around shared members. Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden workspace delete: owner-only billing impact and safer member rehome. Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix workspace delete review findings. Prune pending invites and media on delete, lock the account for the last-workspace guard, fall back to account-owned workspaces for owners, redirect self-hosted last deletes to create, cancel Stripe after local cleanup, align personal-account trials, and gate Index create for owners. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Harden invite accept and account delete edge cases. Stop invite accept from demoting existing roles, expire dead invites on show, preserve flash by avoiding calendar bounces, move media file I/O outside locked delete transactions, and finish account deletion even if Stripe cancel fails. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix remaining invite redirect and media cleanup edge cases. Distinguish already-accepted invites from gone workspaces, rehome members removed from their last shared workspace, capture media paths inside the delete lock, extract orphaned-file cleanup, and use Wayfinder for the expired-invite home link. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix invite current-workspace and account-delete edge cases. Switch invitees onto an invite-account workspace when accepting, prefer same-account fallbacks when removing members, abort account deletion if Stripe cancel fails, and clear avatar media on profile delete. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix Stripe-failure media leak and invite cross-account redirect. Flush workspace media files before billing cancel can abort account delete, and rehome stranded non-owners before picking an invite redirect fallback so current workspace never points across accounts. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Never set cross-account current workspace on member rehome. Keep RemoveMember and account-delete member fallbacks same-account only, clarify the billing-failure flash that workspaces were already removed, and assert storage deletion in media cleanup tests. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Sync Stripe workspace quantity when account delete billing fails. After local workspaces are wiped, a stuck cancelNow must still drop seat quantity so the subscription cannot keep billing the old count. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Prune account invites when owner delete wipes workspaces. Pending and accepted invites are removed with the workspaces so a Stripe cancel failure cannot leave unique email/account rows that block re-invites to a gutted account. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Extract DeleteWorkspaceMedia to purge workspace media rows. Call sites capture returned paths inside the lock and still flush orphaned storage files after commit via DeleteOrphanedMediaFiles. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Redirect to calendar after deleting a workspace with a fallback. When DeleteWorkspace already sets another current workspace, sending the owner to the workspace picker is unnecessary — take them back into the app instead. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Use Wayfinder for invite redirect and logo home links. Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with show.url() and home() route helpers. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Use Wayfinder home() for AcceptInvite logo link. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Extract AcceptInvite title and description into computeds. Keeps the expired/active copy logic out of the template and matches the existing trans() pattern used elsewhere. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix lazy-loading crash when deleting a workspace. isAccountOwner() no longer touches the account relation unless it is already loaded, and delete/rehome queries eager-load account when they need ownership checks under Model::shouldBeStrict(). Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Avoid isAccountOwner during workspace delete fallback. Compare against the already-loaded account owner_id so current-workspace reassignment cannot touch the account relation under shouldBeStrict(). Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Add tests for DeleteWorkspace functionality Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions. * Refactor member removal process to delete or restore stranded members Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended. * Enhance member removal and media management during account deletion Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity. * Enhance user account deletion process with force delete option Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions. * Extract shared delete/invite actions out of fat controllers. Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden delete/invite invariants and replace invite string outcomes. Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish delete/invite teardown APIs and cancel Stripe on empty accounts. Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose clearer stranded-member entry points so cancel never races the invite lock. Co-authored-by: Cursor <cursoragent@cursor.com> * Finish stranded teardown craft: settle outside locks, clearer names. Defer empty-account Stripe cancel until after the account lock, rename stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden multi-account Stripe cancel order and typed stranded settlements. Cancel member personals before the shared account, introduce CancelAccounts and StrandedSettlement::flush so partial Stripe failures leave billing intact. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse strandedMemberOnSharedAccount across delete/invite feature tests. Expand the Pest helper for shared workspaces and owner injection so stranded-member fixtures stop being hand-rolled in every suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Lock the account row during owner account teardown. Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent stranded restores cannot move members off the account before force-delete. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop personal-account restore when leaving a shared account. Invitees abandon their previous personal account on accept, and stranded members are always deleted — matching the real product flow. Co-authored-by: Cursor <cursoragent@cursor.com> * Close the account model and consolidate teardown actions. Block invites to emails that already belong to a registered user — accounts are closed (one user, one account), so members never own a personal account. This removes the whole leftover/restore surface. Consolidate: fold AccountsRequiringCancel/CancelAccounts into CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/ PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new action files. Co-authored-by: Cursor <cursoragent@cursor.com> * Remove orphaned members.errors.already_member translation key. Co-authored-by: Cursor <cursoragent@cursor.com> * Block invitees from creating a workspace on the invite shell. A pending invitee could open workspaces/create (outside EnsureHasWorkspace) and add a workspace (then billing) on their empty signup shell before accept. Accept only tears down an empty shell, so this left an abandoned, billable account. Deny create/store while an invite is pending — the invitee joins via the invite instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten stranded-member fixtures to the closed-account model. Drop the member's empty signup shell in strandedMemberOnSharedAccount and the billing-abort profile test so the setup matches what accept actually leaves (member owns nothing). Remove the never-overridden attachOwner param. Co-authored-by: Cursor <cursoragent@cursor.com> * Bind invite registration to the invited email. The register form shows the invited email as read-only when an invite id is present, and store() rejects a different email for a valid invite. Also fixes a latent bug: EnsureRegistrationEnabled only read the invite id from the query string, so the self-hosted invite registration POST always 404'd. Co-authored-by: Cursor <cursoragent@cursor.com> * Move register validation into RegisterRequest. Inline $request->validate() and the invite-email check move into App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection no longer sniffs a /invites/ redirect string — it resolves the invite id directly; the invite registration test now uses a real invite. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:00:38 +00:00
test('store invite blocks an email that already belongs to a registered user', function () {
User::factory()->create(['email' => 'existing@example.com']);
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'existing@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('email');
$this->assertDatabaseMissing('invites', ['email' => 'existing@example.com']);
Mail::assertNothingQueued();
});
test('store invite requires a role', function () {
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'newmember@example.com',
]);
$response->assertSessionHasErrors('role');
$this->assertDatabaseMissing('invites', ['email' => 'newmember@example.com']);
});
test('store invite persists the chosen role', function () {
$this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'viewer@example.com',
'role' => WorkspaceRole::Viewer->value,
]);
$this->assertDatabaseHas('invites', [
'email' => 'viewer@example.com',
'role' => WorkspaceRole::Viewer->value,
]);
});
2026-01-18 23:33:45 +00:00
test('store invite fails if invite already exists', function () {
Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->user->id,
2026-01-18 23:33:45 +00:00
'email' => 'existing@example.com',
'workspaces' => [$this->workspace->id],
2026-01-18 23:33:45 +00:00
]);
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
2026-01-18 23:33:45 +00:00
'email' => 'existing@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('email');
});
test('store invite fails if user is already member', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
2026-01-18 23:33:45 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
2026-01-18 23:33:45 +00:00
'email' => $member->email,
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('email');
});
// Destroy invite tests
test('destroy invite requires authentication', function () {
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->user->id,
'workspaces' => [$this->workspace->id],
2026-01-18 23:33:45 +00:00
]);
$response = $this->delete(route('app.invites.destroy', $invite));
2026-01-18 23:33:45 +00:00
$response->assertRedirect(route('login'));
});
test('destroy invite deletes invite', function () {
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->user->id,
'workspaces' => [$this->workspace->id],
2026-01-18 23:33:45 +00:00
]);
$response = $this->actingAs($this->user)->delete(route('app.invites.destroy', $invite));
2026-01-18 23:33:45 +00:00
$response->assertRedirect();
expect(Invite::find($invite->id))->toBeNull();
2026-01-18 23:33:45 +00:00
});
test('destroy invite returns 404 for other account invite', function () {
$otherAccount = Account::factory()->create();
$invite = Invite::factory()->create([
'account_id' => $otherAccount->id,
'workspaces' => [],
2026-01-18 23:33:45 +00:00
]);
$response = $this->actingAs($this->user)->delete(route('app.invites.destroy', $invite));
2026-01-18 23:33:45 +00:00
$response->assertNotFound();
});
// Remove member tests
test('remove member requires authentication', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
2026-01-18 23:33:45 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->delete(route('app.members.remove', $member));
2026-01-18 23:33:45 +00:00
$response->assertRedirect(route('login'));
});
test('remove member removes user from workspace', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
2026-01-18 23:33:45 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->delete(route('app.members.remove', $member));
2026-01-18 23:33:45 +00:00
$response->assertRedirect();
expect($this->workspace->hasMember($member))->toBeFalse();
});
Allow account owners to delete workspaces (#208) * Allow owners and admins to delete workspaces from settings. Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant canDelete prop from workspace settings. The settings page is already gated by update (owner/admin), which matches delete. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract workspace delete danger zone into DeleteWorkspace component. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify workspace delete billing copy across locales. Co-authored-by: Cursor <cursoragent@cursor.com> * Match workspace delete card to the delete-account settings pattern. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden workspace and account deletion around shared members. Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden workspace delete: owner-only billing impact and safer member rehome. Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix workspace delete review findings. Prune pending invites and media on delete, lock the account for the last-workspace guard, fall back to account-owned workspaces for owners, redirect self-hosted last deletes to create, cancel Stripe after local cleanup, align personal-account trials, and gate Index create for owners. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Harden invite accept and account delete edge cases. Stop invite accept from demoting existing roles, expire dead invites on show, preserve flash by avoiding calendar bounces, move media file I/O outside locked delete transactions, and finish account deletion even if Stripe cancel fails. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix remaining invite redirect and media cleanup edge cases. Distinguish already-accepted invites from gone workspaces, rehome members removed from their last shared workspace, capture media paths inside the delete lock, extract orphaned-file cleanup, and use Wayfinder for the expired-invite home link. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix invite current-workspace and account-delete edge cases. Switch invitees onto an invite-account workspace when accepting, prefer same-account fallbacks when removing members, abort account deletion if Stripe cancel fails, and clear avatar media on profile delete. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix Stripe-failure media leak and invite cross-account redirect. Flush workspace media files before billing cancel can abort account delete, and rehome stranded non-owners before picking an invite redirect fallback so current workspace never points across accounts. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Never set cross-account current workspace on member rehome. Keep RemoveMember and account-delete member fallbacks same-account only, clarify the billing-failure flash that workspaces were already removed, and assert storage deletion in media cleanup tests. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Sync Stripe workspace quantity when account delete billing fails. After local workspaces are wiped, a stuck cancelNow must still drop seat quantity so the subscription cannot keep billing the old count. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Prune account invites when owner delete wipes workspaces. Pending and accepted invites are removed with the workspaces so a Stripe cancel failure cannot leave unique email/account rows that block re-invites to a gutted account. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Extract DeleteWorkspaceMedia to purge workspace media rows. Call sites capture returned paths inside the lock and still flush orphaned storage files after commit via DeleteOrphanedMediaFiles. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Redirect to calendar after deleting a workspace with a fallback. When DeleteWorkspace already sets another current workspace, sending the owner to the workspace picker is unnecessary — take them back into the app instead. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Use Wayfinder for invite redirect and logo home links. Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with show.url() and home() route helpers. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Use Wayfinder home() for AcceptInvite logo link. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Extract AcceptInvite title and description into computeds. Keeps the expired/active copy logic out of the template and matches the existing trans() pattern used elsewhere. Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Fix lazy-loading crash when deleting a workspace. isAccountOwner() no longer touches the account relation unless it is already loaded, and delete/rehome queries eager-load account when they need ownership checks under Model::shouldBeStrict(). Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Avoid isAccountOwner during workspace delete fallback. Compare against the already-loaded account owner_id so current-workspace reassignment cannot touch the account relation under shouldBeStrict(). Co-authored-by: Paulo Castellano <hello@paulocastellano.com> * Add tests for DeleteWorkspace functionality Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions. * Refactor member removal process to delete or restore stranded members Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended. * Enhance member removal and media management during account deletion Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity. * Enhance user account deletion process with force delete option Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions. * Extract shared delete/invite actions out of fat controllers. Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden delete/invite invariants and replace invite string outcomes. Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish delete/invite teardown APIs and cancel Stripe on empty accounts. Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose clearer stranded-member entry points so cancel never races the invite lock. Co-authored-by: Cursor <cursoragent@cursor.com> * Finish stranded teardown craft: settle outside locks, clearer names. Defer empty-account Stripe cancel until after the account lock, rename stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden multi-account Stripe cancel order and typed stranded settlements. Cancel member personals before the shared account, introduce CancelAccounts and StrandedSettlement::flush so partial Stripe failures leave billing intact. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse strandedMemberOnSharedAccount across delete/invite feature tests. Expand the Pest helper for shared workspaces and owner injection so stranded-member fixtures stop being hand-rolled in every suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Lock the account row during owner account teardown. Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent stranded restores cannot move members off the account before force-delete. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop personal-account restore when leaving a shared account. Invitees abandon their previous personal account on accept, and stranded members are always deleted — matching the real product flow. Co-authored-by: Cursor <cursoragent@cursor.com> * Close the account model and consolidate teardown actions. Block invites to emails that already belong to a registered user — accounts are closed (one user, one account), so members never own a personal account. This removes the whole leftover/restore surface. Consolidate: fold AccountsRequiringCancel/CancelAccounts into CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/ PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new action files. Co-authored-by: Cursor <cursoragent@cursor.com> * Remove orphaned members.errors.already_member translation key. Co-authored-by: Cursor <cursoragent@cursor.com> * Block invitees from creating a workspace on the invite shell. A pending invitee could open workspaces/create (outside EnsureHasWorkspace) and add a workspace (then billing) on their empty signup shell before accept. Accept only tears down an empty shell, so this left an abandoned, billable account. Deny create/store while an invite is pending — the invitee joins via the invite instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten stranded-member fixtures to the closed-account model. Drop the member's empty signup shell in strandedMemberOnSharedAccount and the billing-abort profile test so the setup matches what accept actually leaves (member owns nothing). Remove the never-overridden attachOwner param. Co-authored-by: Cursor <cursoragent@cursor.com> * Bind invite registration to the invited email. The register form shows the invited email as read-only when an invite id is present, and store() rejects a different email for a valid invite. Also fixes a latent bug: EnsureRegistrationEnabled only read the invite id from the query string, so the self-hosted invite registration POST always 404'd. Co-authored-by: Cursor <cursoragent@cursor.com> * Move register validation into RegisterRequest. Inline $request->validate() and the invite-email check move into App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection no longer sniffs a /invites/ redirect string — it resolves the invite id directly; the invite registration test now uses a real invite. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:00:38 +00:00
test('remove member deletes stranded members', function () {
[
'member' => $member,
] = strandedMemberOnSharedAccount(
owner: $this->user,
setMemberCurrent: false,
);
$member->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$this->actingAs($this->user)->delete(route('app.members.remove', $member));
expect($this->workspace->hasMember($member))->toBeFalse();
expect(User::find($member->id))->toBeNull();
});
2026-01-18 23:33:45 +00:00
test('remove member fails for owner', function () {
$response = $this->actingAs($this->user)->delete(route('app.members.remove', $this->user));
2026-01-18 23:33:45 +00:00
$response->assertSessionHasErrors('member');
});
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
// Update role tests
test('update role requires authentication', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Admin->value,
]);
$response->assertRedirect(route('login'));
});
test('update role changes member to admin', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Admin->value,
]);
$response->assertRedirect();
expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Admin->value);
});
test('update role changes member to viewer', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Viewer->value,
]);
$response->assertRedirect();
expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Viewer->value);
});
test('an admin cannot change their own role', function () {
$admin = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($admin->id, ['role' => WorkspaceRole::Admin->value]);
$admin->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($admin)->put(route('app.members.update-role', $admin), [
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('role');
expect($this->workspace->members()->where('user_id', $admin->id)->first()->pivot->role)->toBe(WorkspaceRole::Admin->value);
});
test('an admin cannot remove themselves', function () {
$admin = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($admin->id, ['role' => WorkspaceRole::Admin->value]);
$admin->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($admin)->delete(route('app.members.remove', $admin));
$response->assertSessionHasErrors('member');
expect($this->workspace->members()->where('user_id', $admin->id)->exists())->toBeTrue();
});
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
test('update role changes admin to member', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Admin->value]);
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
$result = $member->createToken('Admin Key');
$token = AccessToken::query()->findOrFail($result->token->id);
$token->forceFill(['workspace_id' => $this->workspace->id])->saveQuietly();
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Member->value,
]);
$response->assertRedirect();
expect($this->workspace->members()->where('user_id', $member->id)->first()->pivot->role)->toBe(WorkspaceRole::Member->value);
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
expect($token->fresh()->revoked)->toBeTrue();
});
test('demoting a member to viewer keeps their mcp oauth grants', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
Scope MCP OAuth tokens to user + workspace (#222) (#245) * Scope MCP OAuth tokens to user + workspace Bind authorization-code grants to the authorizing workspace (via auth codes), inherit workspace on refresh, resolve MCP/API requests from the token instead of current_workspace_id, backfill existing grants, and revoke workspace tokens when a member is removed. Co-authored-by: Cursor <cursoragent@cursor.com> * Add multi-workspace MCP OAuth coverage Cover coexistence of the same client across workspaces, settings list/disconnect scoped to the current workspace, and API key controllers excluding workspace-bound MCP grants. Co-authored-by: Cursor <cursoragent@cursor.com> * Use constrained foreignUuid for oauth_auth_codes.workspace_id Match the project's UUID foreign-key convention instead of a separate foreign() call. Co-authored-by: Cursor <cursoragent@cursor.com> * Localize the MCP OAuth authorize consent screen Wire authorize.blade.php to mcp.* translation keys (including the workspace scope copy) and cover pt-BR rendering. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix invalid Mockery import in bind workspace test CI treats the non-compound `use Mockery` as an ErrorException and aborts the whole parallel suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Inline MCP OAuth workspace backfill into the migration Move the one-shot backfill out of a dedicated Action and wrap it in an explicit transaction so a failure rolls back partial binds/revokes. Co-authored-by: Cursor <cursoragent@cursor.com> * Nest MCP authorize i18n keys and test backfill rollback Group consent-screen copy under mcp.authorize.*, and assert the workspace backfill migration rolls back binds when it fails before commit. Co-authored-by: Cursor <cursoragent@cursor.com> * Hardcode TryPost in the MCP authorize page title Drop the config('app.name') interpolation from the consent screen title. Co-authored-by: Cursor <cursoragent@cursor.com> * Add workspace picker to MCP OAuth consent screen Let users choose which workspace to bind at authorize time instead of always using current_workspace_id; silent re-consent still falls back. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten MCP authorize workspace select spacing Match NativeSelect styling and give the label, control, and helper text room to breathe. Co-authored-by: Cursor <cursoragent@cursor.com> * Convert MCP OAuth consent screen to Inertia Vue Reuse AuthCardLayout, Button, and NativeSelect so the authorize page matches the app UI. Keep native form posts so Passport's external redirect still works for MCP client popups. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP authorize layout with logo and workspace combobox Drop the shield and AuthCardLayout double-logo, put TryPost branding at the top, and reuse the app Combobox pattern for workspace search. Co-authored-by: Cursor <cursoragent@cursor.com> * Align MCP OAuth workspace backfill with mcpOAuth scope Reuse AccessToken::mcpOAuth() so the migration only touches mcp:use grants on non-PAT clients, matching the rest of the codebase. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten MCP OAuth workspace backfill heuristics Only touch connected MCP sessions, bind a sole membership or a valid current workspace, and revoke ambiguous multi-workspace grants instead of guessing the oldest workspace. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop Passport connection override from auth code migration Always use the app default database connection from .env. Co-authored-by: Cursor <cursoragent@cursor.com> * Bind MCP OAuth workspace in AccessTokenRepository Replace the AccessTokenCreated listener with the same Passport repository override pattern used for auth codes, so workspace_id is set at persist. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify AccessTokenRepository workspace binding Drop redundant string casts and the oldest-workspace fallback; keep a small ownedWorkspace/payloadId helper surface instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Passport MCP authorization view from AppServiceProvider Keep configurePassport thin by moving the Inertia consent props into an invokable App\Passport\AuthorizationView class. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify AuthorizationView and cover it with direct tests Use collection higher-order mapping for workspaces/scopes and add focused tests for current-workspace selection and empty-user props. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename BindWorkspaceToAccessTokenTest after listener removal The suite now covers AuthCodeRepository and AccessTokenRepository workspace binding, not an AccessTokenCreated listener. * Fail closed when auth code has no bindable workspace Authorization-code grants no longer fall back to the user's current workspace, so a token cannot be minted for a different tenant than consent. Co-authored-by: Cursor <cursoragent@cursor.com> * Retrigger CI after GitHub Actions infrastructure failures Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden MCP OAuth workspace binding on refresh and backfill Co-authored-by: Cursor <cursoragent@cursor.com> * fix: always show MCP OAuth consent to pick a workspace Disable Passport silent re-consent and require an explicit workspace_id from the consent form, with Passport wiring moved to its own provider. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: sort MCP connected clients by last used Show most recently used OAuth connections first on the workspace MCP settings page. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:59:34 +00:00
$oauth = mcpAccessToken($member, mcpOauthClient(), $this->workspace);
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
$refreshTokenId = (string) Str::uuid();
DB::table('oauth_refresh_tokens')->insert([
'id' => $refreshTokenId,
'access_token_id' => $oauth->id,
'revoked' => false,
'expires_at' => now()->addDay(),
]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Viewer->value,
]);
$response->assertRedirect();
expect($oauth->fresh()->revoked)->toBeFalse()
Make the test suite pass on MySQL (#307) * Give the foreign key a backing index before dropping the unique social_accounts.workspace_id carries a foreign key, and the composite unique index is the only one covering it, as its leftmost prefix. MySQL refuses to drop the sole index backing a foreign key (SQLSTATE[HY000] 1553), so both rehearsal suites failed in beforeEach and never ran a single assertion on MySQL. Add a plain index on workspace_id first; PostgreSQL has no such requirement and simply carries it. This unmasks one assertion underneath that had never executed: the automation graph comparison at DuplicateIdentityMigrationTest.php:419 depended on JSON object key order, which MySQL normalises on storage. (cherry picked from commit 98a494bd2205e873321a18232f63b358ae259fdf) * Compare JSON payloads without depending on key order MySQL normalises JSON object keys (length, then lexicographic) on storage, so an identity comparison against a literal asserts how the driver chose to lay the object out rather than what it contains. PostgreSQL preserves insertion order, which is why these passed there. toEqual compares associative arrays recursively without regard to key order. Applied to every assertion in this class, including the few that pass today only because their keys already happen to match MySQL's ordering. (cherry picked from commit 3124023c548d6c2b8b52126afc6fc5f38d461ea6) * Match logged SQL without depending on identifier quoting Four DB::listen predicates matched 'select * from "post_platforms"'. PostgreSQL quotes identifiers with double quotes and MySQL with backticks, so on MySQL the predicates never matched, the simulated mid-run pause never fired, and the race these tests exist to cover went unexercised while the tests still reported failures elsewhere. Compare against the unquoted form via a small helper. (cherry picked from commit 67a81df5de155e80227df748b34cd8b3cfd744f9) * Cast raw boolean reads in tests so they pass on MySQL Three assertions read oauth_refresh_tokens.revoked through the query builder rather than Eloquent, so no cast applies and the driver's native representation leaks into the test: a real boolean on PostgreSQL, 1 on MySQL. Cast explicitly at the call site. (cherry picked from commit 2911c5c48cf65d24a34a41e667335c40005839a7) * Use a scheduling date inside MySQL's TIMESTAMP range MySQL TIMESTAMP columns end at 2038-01-19, so the 2099 sentinel these tests used is rejected outright with SQLSTATE[22007]. 2037-12-31 still reads as a far-future schedule and works on both engines. (cherry picked from commit bde33eb239cdbd3a5567d4c21e1d85302913cdd7) * Remove the duplicate-identity migration scenario test The suite rebuilt a pre-migration schema by dropping the unique index in beforeEach and re-running the migration by hand, exercising a database state the application never runs in. * Fix the MySQL rollback path and run CI on both engines The migration's down() dropped a unique whose leftmost prefix is an FK column, which MySQL refuses when nothing else backs the constraint (SQLSTATE 1553). It now creates a standalone index first, so migrate:rollback works on MySQL and stays a no-op change for PostgreSQL. up() is untouched: every database already migrated keeps its schema. The rehearsal test calls that down() instead of hand-rolling the drop, so it exercises the real rollback rather than an imitation of it. Matches logged SQL through the connection's query grammar rather than stripping quote characters, and adds a MySQL leg to the backend CI job. * Use a readiness check both database images can run mysql:8.4 installs mysql-community-server-minimal, which ships neither mysqladmin nor the mysql client, so a mysqladmin health command never succeeds and the service never reports healthy. Both images run their init phase without networking, so an open port is the point either engine starts accepting connections - one check covers both, and the per-engine matrix key goes away. * Use each engine's own readiness tool pg_isready and mysqladmin ping are what the respective images ship for this, and the mysql image's entrypoint invokes mysqladmin itself, so it is present. Keeps 20 retries, which MySQL needs to finish initialising. * State the two-engine ceiling as a rule, not a test detail The 2038 TIMESTAMP limit binds anything written to the column, not just the sentinel dates in fixtures, and the same reasoning generalises: what the app supports is the intersection of both engines. * Let the release image connect to MySQL The published image installed only pdo_pgsql, so DB_CONNECTION=mysql failed with "could not find driver" before any query ran - the app supports MySQL but the image people actually deploy could not reach it. mysql-client mirrors the postgresql-client already present, for artisan db and dumps. * Keep "backend" a single required status check Matrixing the job split its check in two, so the "backend" context the branch protection requires was never reported and every PR sat waiting on it. The matrix is now "tests" and a small "backend" job gates on it, which keeps the required check stable however many engines the matrix grows to - and leaves the open PRs mergeable without a rebase. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-29 14:05:33 +00:00
->and((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeFalse()
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
->and($member->fresh()->can('createPost', $this->workspace))->toBeFalse()
->and($member->fresh()->can('view', $this->workspace))->toBeTrue();
});
test('demoting to viewer on one workspace keeps mcp when they remain a member elsewhere', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
$other = Workspace::factory()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$other->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
Scope MCP OAuth tokens to user + workspace (#222) (#245) * Scope MCP OAuth tokens to user + workspace Bind authorization-code grants to the authorizing workspace (via auth codes), inherit workspace on refresh, resolve MCP/API requests from the token instead of current_workspace_id, backfill existing grants, and revoke workspace tokens when a member is removed. Co-authored-by: Cursor <cursoragent@cursor.com> * Add multi-workspace MCP OAuth coverage Cover coexistence of the same client across workspaces, settings list/disconnect scoped to the current workspace, and API key controllers excluding workspace-bound MCP grants. Co-authored-by: Cursor <cursoragent@cursor.com> * Use constrained foreignUuid for oauth_auth_codes.workspace_id Match the project's UUID foreign-key convention instead of a separate foreign() call. Co-authored-by: Cursor <cursoragent@cursor.com> * Localize the MCP OAuth authorize consent screen Wire authorize.blade.php to mcp.* translation keys (including the workspace scope copy) and cover pt-BR rendering. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix invalid Mockery import in bind workspace test CI treats the non-compound `use Mockery` as an ErrorException and aborts the whole parallel suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Inline MCP OAuth workspace backfill into the migration Move the one-shot backfill out of a dedicated Action and wrap it in an explicit transaction so a failure rolls back partial binds/revokes. Co-authored-by: Cursor <cursoragent@cursor.com> * Nest MCP authorize i18n keys and test backfill rollback Group consent-screen copy under mcp.authorize.*, and assert the workspace backfill migration rolls back binds when it fails before commit. Co-authored-by: Cursor <cursoragent@cursor.com> * Hardcode TryPost in the MCP authorize page title Drop the config('app.name') interpolation from the consent screen title. Co-authored-by: Cursor <cursoragent@cursor.com> * Add workspace picker to MCP OAuth consent screen Let users choose which workspace to bind at authorize time instead of always using current_workspace_id; silent re-consent still falls back. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten MCP authorize workspace select spacing Match NativeSelect styling and give the label, control, and helper text room to breathe. Co-authored-by: Cursor <cursoragent@cursor.com> * Convert MCP OAuth consent screen to Inertia Vue Reuse AuthCardLayout, Button, and NativeSelect so the authorize page matches the app UI. Keep native form posts so Passport's external redirect still works for MCP client popups. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP authorize layout with logo and workspace combobox Drop the shield and AuthCardLayout double-logo, put TryPost branding at the top, and reuse the app Combobox pattern for workspace search. Co-authored-by: Cursor <cursoragent@cursor.com> * Align MCP OAuth workspace backfill with mcpOAuth scope Reuse AccessToken::mcpOAuth() so the migration only touches mcp:use grants on non-PAT clients, matching the rest of the codebase. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten MCP OAuth workspace backfill heuristics Only touch connected MCP sessions, bind a sole membership or a valid current workspace, and revoke ambiguous multi-workspace grants instead of guessing the oldest workspace. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop Passport connection override from auth code migration Always use the app default database connection from .env. Co-authored-by: Cursor <cursoragent@cursor.com> * Bind MCP OAuth workspace in AccessTokenRepository Replace the AccessTokenCreated listener with the same Passport repository override pattern used for auth codes, so workspace_id is set at persist. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify AccessTokenRepository workspace binding Drop redundant string casts and the oldest-workspace fallback; keep a small ownedWorkspace/payloadId helper surface instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Passport MCP authorization view from AppServiceProvider Keep configurePassport thin by moving the Inertia consent props into an invokable App\Passport\AuthorizationView class. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify AuthorizationView and cover it with direct tests Use collection higher-order mapping for workspaces/scopes and add focused tests for current-workspace selection and empty-user props. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename BindWorkspaceToAccessTokenTest after listener removal The suite now covers AuthCodeRepository and AccessTokenRepository workspace binding, not an AccessTokenCreated listener. * Fail closed when auth code has no bindable workspace Authorization-code grants no longer fall back to the user's current workspace, so a token cannot be minted for a different tenant than consent. Co-authored-by: Cursor <cursoragent@cursor.com> * Retrigger CI after GitHub Actions infrastructure failures Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden MCP OAuth workspace binding on refresh and backfill Co-authored-by: Cursor <cursoragent@cursor.com> * fix: always show MCP OAuth consent to pick a workspace Disable Passport silent re-consent and require an explicit workspace_id from the consent form, with Passport wiring moved to its own provider. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: sort MCP connected clients by last used Show most recently used OAuth connections first on the workspace MCP settings page. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:59:34 +00:00
$oauth = mcpAccessToken($member, mcpOauthClient(), $this->workspace);
MCP: workspace settings, viewer read access, and token access (#241) * Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 12:54:51 +00:00
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Viewer->value,
]);
$response->assertRedirect();
expect($oauth->fresh()->revoked)->toBeFalse()
->and($member->fresh()->can('createPost', $other))->toBeTrue()
->and($member->fresh()->can('createPost', $this->workspace))->toBeFalse();
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
});
test('update role fails for workspace owner', function () {
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $this->user), [
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('role');
});
test('update role fails with invalid role', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [
'role' => 'invalid',
]);
$response->assertSessionHasErrors('role');
});
test('update role requires authorization', function () {
$member = User::factory()->create([
'account_id' => $this->account->id,
]);
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$nonAdmin = User::factory()->create([
'account_id' => $this->account->id,
]);
refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale Auth pages: - Create AuthSplitLayout with animated feature slides (6 slides, 3 languages) - All auth pages use split layout (form left, visual right) - Add show/hide password toggle with tooltip on Register - Legal footer only shown on Register via showLegal prop Subscribe page: - Redesign to match auth card pattern (centered, clean) - Platform icons, feature checklist, dynamic trial days (trialDays - 1) - Add "Switch workspace" link - Full i18n (en, es, pt-BR) Onboarding: - Rename URLs: step1 -> role, step2 -> connect - Add enforceStep() to prevent skipping/going back steps - Redirect /onboarding to /onboarding/role - Redesign Step2 with AuthSplitLayout and compact platform list - 21 tests covering all step enforcement scenarios Workspaces page: - Redesign with AuthSplitLayout (list with avatars, current badge) Language system: - Move locale from DB to cookie (forever, unencrypted, session.domain) - Create SetLocale middleware (sets cookie if missing, validates against config) - Rename lang/pt-br to lang/pt-BR - Add dayjs es locale Other: - Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard) - ConfirmDeleteModal with text confirmation (sendkit pattern) - i18n for ConfirmDeleteModal internal strings (common.php) - EmptyState component for posts index - Exact match for "All" posts in sidebar - Posts breadcrumbs show current status filter - DialogFooter buttons aligned left - API Keys page redesign with Table, DropdownMenu, EmptyState - Extract CreateApiKeyDialog and InviteMemberDialog to components - Remove API Keys from sidebar - DropdownMenuItem destructive variant for Remove action
2026-03-30 14:53:42 +00:00
$this->workspace->members()->attach($nonAdmin->id, ['role' => WorkspaceRole::Member->value]);
$nonAdmin->update(['current_workspace_id' => $this->workspace->id]);
$response = $this->actingAs($nonAdmin)->put(route('app.members.update-role', $member), [
'role' => WorkspaceRole::Admin->value,
]);
$response->assertForbidden();
});
test('store invite validates email is required', function () {
$response = $this->actingAs($this->user)->post(route('app.invites.store'), []);
$response->assertSessionHasErrors('email');
});
test('store invite validates email format', function () {
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'not-an-email',
]);
$response->assertSessionHasErrors('email');
});
test('store invite validates role must be valid', function () {
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'test@example.com',
'role' => 'owner',
]);
$response->assertSessionHasErrors('role');
});