From 00758fd7944c46438ac30236fe9b2101453d3f11 Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Thu, 10 Sep 2026 23:39:29 +0200 Subject: [PATCH] Expand read-only WEB.DE coverage and IMAP regression tests --- .github/workflows/webde-connection.yml | 13 ++-- README.md | 19 ++++-- test/Integration/ImapTest.php | 49 +++++++++++++- test/Provider/ReadOnlyProbe.php | 90 ++++++++++++++++++++++++++ test/Provider/webde-connection.php | 28 +++++--- test/Unit/MailClientTest.php | 42 ++++++++++++ 6 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 test/Provider/ReadOnlyProbe.php diff --git a/.github/workflows/webde-connection.yml b/.github/workflows/webde-connection.yml index 4e5b727..8348447 100644 --- a/.github/workflows/webde-connection.yml +++ b/.github/workflows/webde-connection.yml @@ -1,11 +1,14 @@ -name: WEB.DE connection +name: WEB.DE read-only integration on: workflow_dispatch: push: - branches: [design/api-examples, main] + branches: [main, test/expand-imap-provider-coverage] paths: - '.github/workflows/webde-connection.yml' - - 'test/Provider/webde-connection.php' + - 'test/Provider/**' + - 'src/**' + - 'composer.json' + - 'composer.lock' permissions: contents: read jobs: @@ -21,8 +24,8 @@ jobs: coverage: none tools: composer:v2 - run: composer install --prefer-dist --no-interaction --no-progress - - name: TLS and authentication only - run: php -d zend.exception_ignore_args=1 test/Provider/webde-connection.php + - name: TLS, authentication and read-only IMAP checks + run: timeout 180 php -d display_errors=0 -d log_errors=0 -d zend.exception_ignore_args=1 test/Provider/webde-connection.php env: EMAIL: ${{ secrets.EMAIL }} EMAIL_PASSWD: ${{ secrets.EMAIL_PASSWD }} diff --git a/README.md b/README.md index 7434da8..0c97159 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,21 @@ runs integration tests against the disposable local Dovecot created by address example, MIME round trips, retries/conflicts, cursors, automatic/manual flags, trash and TLS hostname rejection. No personal mailbox is used by these tests. -The separate **WEB.DE connection** workflow uses GitHub secrets `EMAIL` and -`EMAIL_PASSWD`. It tests only verified TLS and authentication; it never selects a -mailbox or reads/writes messages. It runs on changes to its workflow/test on the PR -branch and main, and supports manual dispatch once available on the default branch. +The separate **WEB.DE read-only integration** workflow uses GitHub secrets `EMAIL` +and `EMAIL_PASSWD`. It verifies TLS/authentication, opens INBOX with EXAMINE, searches +UIDs and reads at most the three newest messages through `get()` and cursor pagination. +It checks manual mode, per-action Seen suppression and unchanged persistent flags. +Attachment descriptors are checked without downloading attachment content. A test +transport guard rejects all write operations. No mail content, addresses, identifiers, +server responses or exception traces are logged or uploaded as artifacts. An empty +INBOX passes connection/cursor checks and explicitly skips message-dependent checks. +Concurrent deletion or flag changes by another client can fail the live assertions. + +The same read-only probe is exercised against seeded Dovecot in normal CI, which also +covers attachment byte limits and failed APPEND without automatic source flags. +The provider workflow runs on relevant source/dependency/test changes on main and the +`test/expand-imap-provider-coverage` branch, and supports manual dispatch. It is separate +from ordinary PR tests and never exposes secrets to fork pull requests. WEB.DE must have IMAP access enabled; two-factor accounts may need an app password. See [WEB.DE's server settings](https://hilfe.web.de/pop-imap/imap/imap-serverdaten.html). diff --git a/test/Integration/ImapTest.php b/test/Integration/ImapTest.php index eacd5da..a63694d 100644 --- a/test/Integration/ImapTest.php +++ b/test/Integration/ImapTest.php @@ -57,8 +57,11 @@ public function testManualAutomaticAndPerActionOverrides(): void public function testCursorDoesNotReplayHighestUidAndStaleReferencesFail(): void { $transport = new ImapTransport('localhost','test','test-secret',1993); + $status = $transport->select('INBOX'); + $existing = $transport->search([]); + $cursor = (new Reference(hash('sha256','localhost:1993:test'),'INBOX',(int)$status['uidvalidity'],$existing === [] ? 0 : max($existing)))->encode(); for ($i=0;$i<3;$i++) { $transport->append('INBOX',Mime::build(new Email(from:'seed@example.org',subject:'Fixture '.$i),[])); } - $client = $this->client(); $batch = $client->listNew(limit:2); + $client = $this->client(); $batch = $client->listNew(after:$cursor,limit:2); self::assertCount(2,$batch->emails); self::assertNotContains('\\Seen',$batch->emails[0]->flags()); $next = $client->listNew(after:$batch->nextCursor,limit:2); self::assertCount(1,$next->emails); @@ -110,4 +113,48 @@ public function move(int $uid,string $folder): array { return $this->inner->move catch (\RuntimeException $error) { self::assertSame('Simulated lost APPEND response.',$error->getMessage()); } self::assertNotNull($client->saveDraft($email)->id()); self::assertSame(1,$transport->appends); } + public function testLiveProviderProbeAgainstDisposableInbox(): void + { + require_once dirname(__DIR__) . '/Provider/ReadOnlyProbe.php'; + $transport = new ImapTransport('localhost','test','test-secret',1993); + for ($i=0;$i<3;$i++) { + $email = (new Email(from:'seed@example.org',subject:'Provider fixture '.$i))->withMarkdown('**Body**'); + $file = Attachment::fromBytes('binary.bin','application/octet-stream',"\x00\xfffixture"); + $transport->append('INBOX',Mime::build($email->attach($file),[$file])); + } + $results = \Phore\MailClient\Test\Provider\ReadOnlyProbe::run($transport,hash('sha256','localhost:1993:test')); + self::assertCount(5,$results); + self::assertContains('PASS: Persistent flags unchanged after reads.',$results); + } + public function testAttachmentLimitFailureAndReadLeaveFlagsUnchanged(): void + { + $client = $this->client(); + $file = Attachment::fromBytes('binary.bin','application/octet-stream',"\x00\xff1234"); + $saved = $client->saveDraft((new Email())->attach($file)); + $fetched = $client->get($saved->id()); + $remote = $fetched->attachments()[0]; + self::assertNull($remote->content); + try { $client->openAttachment($fetched,$remote,maxBytes:5); self::fail('Oversized attachment accepted'); } + catch (\RuntimeException $error) { self::assertSame('Attachment exceeds byte limit.',$error->getMessage()); } + $stream = $client->openAttachment($fetched,$remote,maxBytes:6); + try { self::assertSame("\x00\xff1234",stream_get_contents($stream)); } finally { fclose($stream); } + self::assertNotContains('\\Seen',$client->get($saved->id())->flags()); + } + public function testFailedAppendDoesNotSetAutomaticSourceFlags(): void + { + $manual = $this->client(); + $source = $manual->saveDraft(new Email(from:'other@example.org',to:'me@example.org')); + $inner = new ImapTransport('localhost','test','test-secret',1993); + $transport = $this->createMock(Transport::class); + foreach (['select','search','metadata','part'] as $method) { + $transport->method($method)->willReturnCallback($inner->$method(...)); + } + $transport->expects(self::once())->method('append')->willThrowException(new \RuntimeException('APPEND rejected')); + $transport->expects(self::never())->method('flag'); + $client = new MailClient($transport,hash('sha256','localhost:1993:test'),from:'me@example.org'); + try { $client->saveDraft($client->reply($source,'Reply')); self::fail('Expected APPEND failure'); } + catch (\RuntimeException $error) { self::assertSame('APPEND rejected',$error->getMessage()); } + $flags = $manual->get($source->id())->flags(); + self::assertNotContains('\\Answered',$flags); self::assertNotContains('\\Seen',$flags); + } } diff --git a/test/Provider/ReadOnlyProbe.php b/test/Provider/ReadOnlyProbe.php new file mode 100644 index 0000000..8b558a1 --- /dev/null +++ b/test/Provider/ReadOnlyProbe.php @@ -0,0 +1,90 @@ + Public, content-free check results. */ + public static function run(Transport $inner, string $account): array + { + // Fail closed even if a future client regression attempts an automatic write. + $transport = new class($inner) implements Transport { + public function __construct(private Transport $inner) {} + public function select(string $folder, bool $write = false): array { + if ($write) { throw new RuntimeException('Provider probe attempted write selection.'); } + return $this->inner->select($folder); + } + public function search(array $criteria): array { return $this->inner->search($criteria); } + public function metadata(int $uid): array { return $this->inner->metadata($uid); } + public function part(int $uid, string $section, int $maxBytes): string { return $this->inner->part($uid,$section,$maxBytes); } + public function append(string $folder, string $mime): void { throw new RuntimeException('Provider probe attempted APPEND.'); } + public function flag(int $uid, string $flag, bool $add): void { throw new RuntimeException('Provider probe attempted STORE.'); } + public function move(int $uid, string $folder): array { throw new RuntimeException('Provider probe attempted MOVE.'); } + }; + $status = $transport->select('INBOX'); + self::check((int)($status['uidvalidity'] ?? 0) > 0,'INBOX UIDVALIDITY'); + $results = ['PASS: INBOX opened read-only with UIDVALIDITY.']; + $ids = $transport->search(['after'=>0]); + self::check(count($ids) === count(array_unique($ids)), 'Unique search UIDs'); + foreach ($ids as $uid) { self::check(is_int($uid) && $uid > 0, 'Positive search UIDs'); } + sort($ids,SORT_NUMERIC); + $results[] = 'PASS: UID search.'; + $client = new MailClient($transport,$account,mode:MailClient::MODE_MANUAL); + if ($ids === []) { + $batch = $client->listNew(limit:1); + self::check($batch->emails === [],'Empty INBOX batch'); + $cursor = Reference::decode($batch->nextCursor,$account,true); + self::check($cursor->uid === 0 && $cursor->validity === (int)$status['uidvalidity'],'Empty INBOX cursor'); + $results[] = 'PASS: Empty INBOX cursor.'; + $results[] = 'SKIP: MIME reads, pagination and flag checks require at least one message.'; + return $results; + } + // Read at most the three newest messages; never download attachment content. + $sample = array_slice($ids,-3); + $before = []; $references = []; + foreach ($sample as $uid) { + $before[$uid] = self::flags($transport->metadata($uid)); + $id = (new Reference($account,'INBOX',(int)$status['uidvalidity'],$uid))->encode(); + $references[] = $id; + $email = $client->get($id); + self::check($email->id() === $id,'Message reference round trip'); + self::check(self::flags(['FLAGS'=>$email->flags()]) === $before[$uid],'Manual read flags'); + foreach ($email->attachments() as $attachment) { + self::check($attachment->content === null && $attachment->sourceId === $id,'Lazy attachment descriptor'); + } + } + $results[] = 'PASS: Bounded manual MIME reads and lazy attachment descriptors.'; + // Exercise the public per-action switch too, without allowing any STORE. + $client->setAutomaticMode(true)->setAutomaticMode(false,'seen'); + $cursor = (new Reference($account,'INBOX',(int)$status['uidvalidity'],$sample[0]-1))->encode(); + foreach ($references as $id) { + $batch = $client->listNew(after:$cursor,limit:1); + self::check(count($batch->emails) === 1 && $batch->emails[0]->id() === $id,'Single-message cursor page'); + self::check($batch->nextCursor === $id,'Cursor advances to returned UID'); + $cursor = $batch->nextCursor; + } + // New arrivals are allowed, but the last UID must never replay (IMAP n:* edge). + foreach ($transport->search(['after'=>end($sample)]) as $uid) { + self::check($uid > end($sample),'No UID replay'); + } + $results[] = 'PASS: Cursor pagination, no replay and automatic Seen disabled.'; + foreach ($sample as $uid) { + self::check(self::flags($transport->metadata($uid)) === $before[$uid],'Persistent flags unchanged'); + } + $results[] = 'PASS: Persistent flags unchanged after reads.'; + return $results; + } + private static function flags(array $metadata): array + { + // Recent is session-specific, not a persistent user flag. + $flags = array_values(array_filter(array_map('strtolower',$metadata['FLAGS'] ?? []),static fn(string $flag): bool => $flag !== '\\recent')); + sort($flags,SORT_STRING); return $flags; + } + private static function check(bool $condition, string $label): void + { if (!$condition) { throw new RuntimeException('Provider check failed: ' . $label); } } +} diff --git a/test/Provider/webde-connection.php b/test/Provider/webde-connection.php index bec7da0..97a2ee9 100644 --- a/test/Provider/webde-connection.php +++ b/test/Provider/webde-connection.php @@ -1,28 +1,36 @@ ['verify_peer'=>true,'verify_peer_name'=>true,'peer_name'=>'imap.web.de']]); $socket = @stream_socket_client('tls://imap.web.de:993',$errno,$error,20,STREAM_CLIENT_CONNECT,$context); - if ($socket === false) { - fwrite(STDERR,"FAIL: WEB.DE TCP/TLS preflight failed; authentication was not attempted.\n"); exit(1); - } + if ($socket === false) { throw new RuntimeException('TLS preflight failed.'); } fclose($socket); echo "PASS: WEB.DE TCP/TLS preflight with certificate verification.\n"; - $client = MailClient::connect(host:'imap.web.de',username:$email,password:$password,port:993,mode:MailClient::MODE_MANUAL); - echo "PASS: WEB.DE IMAP TLS connection and authentication succeeded. No messages accessed.\n"; + $stage = 'IMAP authentication'; + $transport = new ImapTransport('imap.web.de',$email,$password,993); + echo "PASS: WEB.DE IMAP TLS connection and authentication succeeded.\n"; + $stage = 'read-only INBOX/MIME/cursor/flag checks'; + $account = hash('sha256','imap.web.de:993:' . $email); + foreach (ReadOnlyProbe::run($transport,$account) as $result) { echo $result . "\n"; } } catch (Throwable $exception) { - // Do not print exceptions, usernames, server responses, credentials or traces. - if (str_starts_with($exception->getMessage(),'IMAP connection failed: ')) { fwrite(STDERR,$exception->getMessage() . "\n"); } - fwrite(STDERR,"FAIL: WEB.DE TLS connection or authentication failed. Check IMAP access, credentials/app password and runner connectivity.\n"); exit(1); + // Never print exception messages, usernames, responses, credentials or traces. + fwrite(STDERR,"FAIL: WEB.DE " . $stage . ". No message data logged.\n"); exit(1); } diff --git a/test/Unit/MailClientTest.php b/test/Unit/MailClientTest.php index 64b9193..49913d1 100644 --- a/test/Unit/MailClientTest.php +++ b/test/Unit/MailClientTest.php @@ -45,4 +45,46 @@ public function testUnknownAutomaticActionIsRejected(): void $client = new MailClient($this->createStub(Transport::class),'account'); $this->expectException(\InvalidArgumentException::class); $client->setAutomaticMode(true,'delete'); } + public function testInvalidLimitsFailBeforeIo(): void + { + $transport = $this->createMock(Transport::class); + $transport->expects(self::never())->method('select'); + $client = new MailClient($transport,'account'); + foreach ([0,501,-1] as $limit) { + try { $client->listNew(limit:$limit); self::fail('Invalid limit accepted'); } + catch (\InvalidArgumentException) { self::assertTrue(true); } + } + } + public function testFolderCursorAndMalformedCursorFailBeforeIo(): void + { + $transport = $this->createMock(Transport::class); + $transport->expects(self::never())->method('select'); + $client = new MailClient($transport,'account'); + foreach (['not-a-cursor',(new Reference('account','Drafts',1,0))->encode()] as $cursor) { + try { $client->listNew(after:$cursor); self::fail('Invalid cursor accepted'); } + catch (\InvalidArgumentException) { self::assertTrue(true); } + } + } + public function testProtocolControlledFlagsCannotBeSetExplicitly(): void + { + $transport = $this->createMock(Transport::class); + $transport->expects(self::never())->method('select'); + $client = new MailClient($transport,'account'); + $email = (new Email())->onServer((new Reference('account','INBOX',1,1))->encode(),[]); + foreach (['\\Deleted','\\Recent',"bad flag","x\r\nSTORE"] as $flag) { + try { $client->addFlag($email,$flag); self::fail('Invalid flag accepted'); } + catch (\InvalidArgumentException) { self::assertTrue(true); } + } + } + public function testProviderProbeReportsSkippedMessageChecksOnEmptyInbox(): void + { + require_once dirname(__DIR__) . '/Provider/ReadOnlyProbe.php'; + $transport = $this->createMock(Transport::class); + $transport->expects(self::exactly(2))->method('select')->with('INBOX')->willReturn(['uidvalidity'=>7]); + $transport->expects(self::exactly(2))->method('search')->with(['after'=>0])->willReturn([]); + foreach (['metadata','part','append','flag','move'] as $method) { $transport->expects(self::never())->method($method); } + $results = \Phore\MailClient\Test\Provider\ReadOnlyProbe::run($transport,'account'); + self::assertContains('PASS: Empty INBOX cursor.',$results); + self::assertSame('SKIP: MIME reads, pagination and flag checks require at least one message.',end($results)); + } }