File: /home/xedaptot/ai.naniguide.com/tests/Feature/SendingIdentityVisibilityTest.php
<?php
/**
* SendingIdentityVisibilityTest — pins the design-doc § 9 contract on
* `IdentityCatalog::valuesForCustomer` (and `SendingIdentity::scopeVisibleTo`)
* for the "user does NOT own a sending server" case — i.e. the plan flows
* the customer onto a system-attached SendingServer.
*
* Past incident 2026-05-20 (Abu Sadeq / marketing.zartech.net): the admin
* Sender Identity tab labeled its checkbox "Available for all" but the save
* path wrote `enabled` instead of `shared`. Customer Free plan → SendGrid
* primary server → 5 admin-managed identities sitting at `enabled=true,
* shared=false` → customer campaign From: dropdown empty. This test catches
* that class of regression by asserting the contract from the customer's
* side regardless of what the admin UI does.
*/
use App\Model\Customer;
use App\Model\Plan;
use App\Model\PlanEntitlement;
use App\Model\PlansSendingServer;
use App\Model\SendingIdentity;
use App\Model\SendingServer;
use App\Model\Subscription;
use App\SendingServers\Identities\IdentityCatalog;
use App\SendingServers\Identities\IdentityKind;
use App\SendingServers\Identities\IdentityStatus;
use App\SendingServers\Identities\ManagementMode;
use App\Services\Plans\Entitlements\EntitlementKey;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
// Customer that uses the system-attached server (NOT their own).
$this->customer = Customer::forceCreate([
'uid' => uniqid('vis_c_'),
'timezone' => 'UTC',
'status' => 'active',
]);
// Plan: explicit HAS_OWN_SENDING_SERVER = false (system server flow).
$this->plan = Plan::forceCreate([
'uid' => uniqid('vis_plan_'),
'name' => 'System Server Plan',
'currency_id' => 1,
'frequency_amount' => 1,
'frequency_unit' => 'month',
'price' => 0,
'status' => 'active',
]);
PlanEntitlement::factory()->for($this->plan, 'plan')
->forKey(EntitlementKey::HAS_OWN_SENDING_SERVER)
->create(); // enabled defaults to false → plan uses system server
// Admin-owned SendingServer (customer_id = NULL) attached to the plan
// as primary — this is the server whose identities the customer must
// be able to pick from.
$this->server = SendingServer::factory()->create([
'admin_id' => 1,
'customer_id' => null,
'type' => 'smtp', // generic; cross-domain irrelevant for visibility
'status' => SendingServer::STATUS_ACTIVE,
]);
PlansSendingServer::forceCreate([
'plan_id' => $this->plan->id,
'sending_server_id' => $this->server->id,
'is_primary' => true,
'fitness' => 100,
]);
// Active subscription binds customer → plan.
$this->sub = Subscription::create([
'uid' => (string) \Illuminate\Support\Str::uuid(),
'customer_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'status' => 'active',
'current_period_ends_at' => now()->addMonth(),
]);
$this->catalog = app(IdentityCatalog::class);
});
afterEach(function () {
DB::table('sending_identities')->where('sending_server_id', $this->server->id)->delete();
DB::table('plans_sending_servers')->where('plan_id', $this->plan->id)->delete();
DB::table('plan_entitlements')->where('plan_id', $this->plan->id)->delete();
$this->sub->delete();
$this->server->delete();
$this->plan->delete();
$this->customer->delete();
});
// ─── core contract ──────────────────────────────────────────────────────────
test('shared=true admin-managed identity IS visible to customer on system server', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id,
'customer_id' => null, // admin-managed
'kind' => IdentityKind::EMAIL,
'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, // <-- under test
'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->toContain('[email protected]');
});
test('shared=false admin-managed identity is NOT visible to customer', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id,
'customer_id' => null,
'kind' => IdentityKind::EMAIL,
'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false, // <-- under test
'enabled' => true, // enabled flips don't help
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->not->toContain('[email protected]');
});
test('mixed batch: dropdown contains shared rows, hides unshared rows', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false, 'enabled' => true,
]);
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->toContain('[email protected]');
expect($values)->toContain('[email protected]');
expect($values)->not->toContain('[email protected]');
});
test('enabled=false hides identity even when shared=true', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => false, // soft-paused
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->not->toContain('[email protected]');
});
test('archived (non-VERIFIED) identity is NOT visible even if shared=true', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::ARCHIVED, // not verified
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->not->toContain('[email protected]');
});
// ─── regression test for the zartech 2026-05-20 bug ──────────────────────────
test('regression 2026-05-20: legacy state enabled=true + shared=false → invisible', function () {
// Reproduces the exact pre-fix state seen on marketing.zartech.net:
// VendorSync inserted rows with shared=false default; admin tick of
// "Available for all" wrote enabled=true (the wrong column) so shared
// stayed false; visibility query returned 0 results.
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false, 'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
// Bug repro: must be EMPTY in this state. Post-backfill migration runs
// shared = enabled for admin-managed rows; afterwards the row would
// satisfy the visibility query.
expect($values)->not->toContain('[email protected]');
});
// ─── bulkUpdateShared writes the correct column ──────────────────────────────
test('bulkUpdateShared flips shared on selected, false on the rest', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false, 'enabled' => true,
]);
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true, // currently shared
]);
$this->server->bulkUpdateShared([
'emails' => ['[email protected]'],
'domains' => [],
]);
$pick = SendingIdentity::where('value', '[email protected]')->first();
$skip = SendingIdentity::where('value', '[email protected]')->first();
expect($pick->shared)->toBeTrue();
expect($skip->shared)->toBeFalse(); // was true, now false (not in selection)
// `enabled` is left untouched by bulkUpdateShared (separate concern).
expect($pick->enabled)->toBeTrue();
expect($skip->enabled)->toBeTrue();
});
// ─── bulkUpdateShared edge cases ────────────────────────────────────────────
test('bulkUpdateShared with empty selection flips ALL to false', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
// Admin unchecks every box and saves → controller forwards empty arrays.
$this->server->bulkUpdateShared(['emails' => [], 'domains' => []]);
expect(SendingIdentity::where('value', '[email protected]')->first()->shared)->toBeFalse();
expect(SendingIdentity::where('value', '[email protected]')->first()->shared)->toBeFalse();
});
test('bulkUpdateShared is scoped to THIS server, never touches other servers', function () {
// Second admin-owned server in the same instance — a leakage of bulk
// updates across servers would be a security/visibility regression.
$otherServer = SendingServer::factory()->create([
'admin_id' => 1, 'customer_id' => null,
'type' => 'smtp', 'status' => SendingServer::STATUS_ACTIVE,
]);
SendingIdentity::create([
'sending_server_id' => $otherServer->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true, // shared on the OTHER server
]);
// Bulk-share an identity on $this->server; pass the OTHER server's
// identity value in the payload (simulates a malicious / buggy POST).
$this->server->bulkUpdateShared([
'emails' => ['[email protected]'],
'domains' => [],
]);
// The other server's identity must NOT be touched — bulkUpdateShared
// scopes its UPDATE to $this->identities() (this server only).
$other = SendingIdentity::where('sending_server_id', $otherServer->id)
->where('value', '[email protected]')->first();
expect($other->shared)->toBeTrue(); // untouched
DB::table('sending_identities')->where('sending_server_id', $otherServer->id)->delete();
$otherServer->delete();
});
// ─── customer-owned identities (branches 1+2 of scopeVisibleTo) ─────────────
test('admin-server identity owned by customer X IS visible to X even if shared=false', function () {
// Branch 3 of scopeVisibleTo: admin-server, shared=false BUT customer_id=X
// means X registered this identity themselves (manual add or self-service
// vendor-sync). Only X sees it; other customers don't ("Private").
SendingIdentity::create([
'sending_server_id' => $this->server->id,
'customer_id' => $this->customer->id, // owned by THIS customer
'kind' => IdentityKind::EMAIL,
'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false, // NOT shared
'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->toContain('[email protected]');
});
test('admin-server identity owned by customer Y is NOT visible to customer X', function () {
// Same scenario but from a different customer's perspective.
$other = Customer::forceCreate([
'uid' => uniqid('vis_c_other_'), 'timezone' => 'UTC', 'status' => 'active',
]);
SendingIdentity::create([
'sending_server_id' => $this->server->id,
'customer_id' => $other->id, // owned by OTHER customer
'kind' => IdentityKind::EMAIL,
'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false,
'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->not->toContain('[email protected]');
$other->delete();
});
test('server-less LOCAL_DKIM identity is visible only to its owner', function () {
// Branch 1 of scopeVisibleTo: sending_server_id IS NULL → owner-only.
$other = Customer::forceCreate([
'uid' => uniqid('vis_c_dkim_'), 'timezone' => 'UTC', 'status' => 'active',
]);
// Customer-X-owned LOCAL_DKIM domain.
SendingIdentity::create([
'sending_server_id' => null,
'customer_id' => $this->customer->id,
'kind' => IdentityKind::DOMAIN,
'value' => 'my.example.com',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::LOCAL_DKIM,
'shared' => false, // shared is irrelevant for server-less
'enabled' => true,
'dkim_private' => 'TEST_PRIVATE_KEY',
'dkim_public' => 'TEST_PUBLIC_KEY',
]);
// The plan's primary server is type=smtp which DOES implement
// AllowsLocalDomainVerify, so the LOCAL_DKIM identity passes isAllowedBy.
$own = $this->catalog->valuesForCustomer($this->customer);
expect($own)->toContain('my.example.com');
// Different customer must not see it.
$foreign = $this->catalog->valuesForCustomer($other);
expect($foreign)->not->toContain('my.example.com');
$other->delete();
});
// ─── cross-server isAllowedBy gate (vendor-strict primary) ───────────────────
test('primary server without AllowsCrossSendingDomain hides identity tied to a different server', function () {
// Promote the plan's primary to a vendor-strict driver (sendgrid-api
// does NOT implement AllowsCrossSendingDomain). An identity tied to
// the OLD primary (type=smtp) becomes ineligible — sendgrid-api would
// reject the From: at send time, so the dropdown hides it upfront.
$strict = SendingServer::factory()->create([
'admin_id' => 1,
'customer_id' => null,
'type' => 'sendgrid-api',
'config' => ['sendgrid_api_key' => 'SG.fake'],
'status' => SendingServer::STATUS_ACTIVE,
]);
// Re-point the plan's primary to the strict server.
DB::table('plans_sending_servers')->where('plan_id', $this->plan->id)
->update(['sending_server_id' => $strict->id]);
// Identity sits on the original smtp server (this->server), shared=true.
SendingIdentity::create([
'sending_server_id' => $this->server->id, // smtp, NOT the primary
'customer_id' => null,
'kind' => IdentityKind::EMAIL,
'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true,
'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
// Visibility scope passes (shared=true), but cross-server narrow
// (isAllowedBy(sendgrid-api primary) returns false because sendgrid-api
// doesn't implement AllowsCrossSendingDomain) filters it out.
expect($values)->not->toContain('[email protected]');
DB::table('sending_identities')->where('sending_server_id', $strict->id)->delete();
$strict->delete();
});
test('identity tied to the SAME server as plan-primary is always allowed (isAllowedBy short-circuit)', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, // == plan primary
'customer_id' => null,
'kind' => IdentityKind::EMAIL,
'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true,
'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->toContain('[email protected]');
});
// ─── plan has NO primary server attached ────────────────────────────────────
test('plan without primary server: all visible identities surface (no isAllowedBy narrow)', function () {
// Detach the primary so plan->primarySendingServer() returns null.
// valuesForCustomer branch line 60-67: "No plan-bound system server →
// expose everything visible. (Own-server flow handles its own routing
// at send time.)"
DB::table('plans_sending_servers')->where('plan_id', $this->plan->id)->delete();
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
$values = $this->catalog->valuesForCustomer($this->customer);
expect($values)->toContain('[email protected]');
});
// ─── droplistForCustomer formatting (autocomplete API shape) ────────────────
test('droplistForCustomer wraps an EMAIL identity in {text,value,desc} for the autocomplete API', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
$rows = $this->catalog->droplistForCustomer($this->customer, null);
// Per IdentityCatalog::formatDroplist, EMAIL rows have a 'value' key
// (DOMAIN rows have 'subfix' instead). The autocomplete JS reads
// 'value' for the chosen From: address.
$emailRow = collect($rows)->first(
fn ($r) => is_array($r) && ($r['value'] ?? null) === '[email protected]'
);
expect($emailRow)->not->toBeNull();
expect($emailRow)->toHaveKeys(['text', 'value', 'desc']);
});
// ─── Campaign::rules() backend enforcement of from_email ─────────────────────
test('Campaign::rules from_email accepts a verified shared identity', function () {
SendingIdentity::create([
'sending_server_id' => $this->server->id, 'customer_id' => null,
'kind' => IdentityKind::EMAIL, 'value' => '[email protected]',
'status' => IdentityStatus::VERIFIED, 'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => true, 'enabled' => true,
]);
$campaign = new \App\Model\Campaign();
$campaign->customer_id = $this->customer->id;
$campaign->use_default_sending_server_from_email = false;
$campaign->setRelation('customer', $this->customer);
$rules = $campaign->rules();
$validator = \Illuminate\Support\Facades\Validator::make(
['from_email' => '[email protected]', 'name' => 'X', 'subject' => 'S',
'from_name' => 'N', 'reply_to' => '[email protected]'],
$rules
);
expect($validator->errors()->has('from_email'))->toBeFalse();
});
test('Campaign::rules from_email rejects an unverified address (backend gate)', function () {
// No identity created — verified list is empty. Posting a from_email
// must be rejected by the closure rule.
$campaign = new \App\Model\Campaign();
$campaign->customer_id = $this->customer->id;
$campaign->use_default_sending_server_from_email = false;
$campaign->setRelation('customer', $this->customer);
$rules = $campaign->rules();
$validator = \Illuminate\Support\Facades\Validator::make(
['from_email' => '[email protected]', 'name' => 'X', 'subject' => 'S',
'from_name' => 'N', 'reply_to' => '[email protected]'],
$rules
);
expect($validator->errors()->has('from_email'))->toBeTrue();
});
test('Campaign::rules from_email skips closure when allowUnverifiedFromEmailAddress=true', function () {
// Flip the SendingServer's option so allowUnverifiedFromEmailAddress() returns true.
$this->server->setOption('allow_unverified_from_email', 'yes');
$this->server->save();
$campaign = new \App\Model\Campaign();
$campaign->customer_id = $this->customer->id;
$campaign->use_default_sending_server_from_email = false;
$campaign->setRelation('customer', $this->customer);
$rules = $campaign->rules();
$validator = \Illuminate\Support\Facades\Validator::make(
['from_email' => '[email protected]', 'name' => 'X', 'subject' => 'S',
'from_name' => 'N', 'reply_to' => '[email protected]'],
$rules
);
expect($validator->errors()->has('from_email'))->toBeFalse();
});
test('Campaign::rules from_email skips closure when use_default_sending_server_from_email=true', function () {
// Server's own default is substituted at send time — the from_email
// field is just metadata; gate doesn't apply.
$campaign = new \App\Model\Campaign();
$campaign->customer_id = $this->customer->id;
$campaign->use_default_sending_server_from_email = true;
$campaign->setRelation('customer', $this->customer);
$rules = $campaign->rules();
$validator = \Illuminate\Support\Facades\Validator::make(
['from_email' => '[email protected]', 'name' => 'X', 'subject' => 'S',
'from_name' => 'N', 'reply_to' => '[email protected]'],
$rules
);
expect($validator->errors()->has('from_email'))->toBeFalse();
});