{{'FIELD_REQUIRED'|translate}}
{{'SIGNUP_PASSWORD_DIFFERENT'|translate}}
@@ -48,7 +48,7 @@
-
+
Sign-Up !
diff --git a/frontend/e2e/auth.spec.js b/frontend/e2e/auth.spec.js
new file mode 100644
index 00000000..f4955408
--- /dev/null
+++ b/frontend/e2e/auth.spec.js
@@ -0,0 +1,77 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { waitForAppReady } = require('./helpers');
+
+test.describe('F4 — Authentication', () => {
+ test('sign up: submitting the form shows a confirmation toast', async ({ page }) => {
+ const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 10000)}`;
+ const username = `newuser${uniqueSuffix}`;
+ const email = `${username}@example.com`;
+
+ await page.goto('/#/signup');
+ await waitForAppReady(page);
+
+ await page.locator('[data-testid="signup-username-input"]').fill(username);
+ await page.locator('[data-testid="signup-email-input"]').fill(email);
+ await page.locator('[data-testid="signup-password-input"]').fill('Passw0rd!');
+ await page.locator('[data-testid="signup-website-input"]').fill('https://testwebsite.com');
+ await page.locator('[data-testid="signup-confirm-password-input"]').fill('Passw0rd!');
+
+ await page.getByRole('button', { name: /sign.?up/i }).click();
+
+ // On success the controller shows a toast and redirects to featured.
+ // Check for the toast before the redirect completes so it isn't dismissed.
+ await expect(page.getByText(/check your mailbox/i)).toBeVisible();
+ await expect(page).toHaveURL('/#/');
+ });
+
+ test('sign in: valid credentials log the user in', async ({ page }) => {
+ await page.goto('/#/signin');
+ await waitForAppReady(page);
+
+ await page.locator('[data-testid="signin-username-input"]').fill('testuser');
+ await page.locator('[data-testid="signin-password-input"]').fill('Password1');
+ await page.locator('[data-testid="signin-submit-button"]').click();
+
+ // Auth service shows this toast on success
+ await expect(page.getByText('You are now successfully logged in')).toBeVisible();
+ });
+
+ test('sign in: wrong password shows an error toast', async ({ page }) => {
+ await page.goto('/#/signin');
+ await waitForAppReady(page);
+
+ await page.locator('[data-testid="signin-username-input"]').fill('testuser');
+ await page.locator('[data-testid="signin-password-input"]').fill('wrongpassword');
+ await page.locator('[data-testid="signin-submit-button"]').click();
+
+ // The interceptor translates INVALID_CREDENTIALS and shows it in a toast
+ await expect(page.getByText(/wrong credentials/i)).toBeVisible();
+ // User stays on the sign-in page
+ await expect(page).toHaveURL(/#\/signin/);
+ });
+
+ test('sign out: user name disappears from the header', async ({ page }) => {
+ await page.goto('/#/signin');
+ await waitForAppReady(page);
+
+ await page.locator('[data-testid="signin-username-input"]').fill('testuser');
+ await page.locator('[data-testid="signin-password-input"]').fill('Password1');
+ await page.locator('[data-testid="signin-submit-button"]').click();
+ await expect(page.getByText('You are now successfully logged in')).toBeVisible();
+
+ // Find and click the sign-out control in the user menu
+ await page.getByText('testuser').click();
+ await page.getByText(/sign.?out|log.?out|disconnect/i).click();
+
+ await expect(page.getByText('You are now disconnected')).toBeVisible();
+ });
+
+ test('GitHub OAuth: the GitHub login button is present', async ({ page }) => {
+ await page.goto('/#/signin');
+ await waitForAppReady(page);
+
+ // The button contains a GitHub icon; test its presence only (OAuth flow is external)
+ await expect(page.locator('.fa-github').first()).toBeVisible();
+ });
+});
diff --git a/frontend/e2e/helpers.js b/frontend/e2e/helpers.js
new file mode 100644
index 00000000..014e35a6
--- /dev/null
+++ b/frontend/e2e/helpers.js
@@ -0,0 +1,72 @@
+/**
+ * Shared helpers for E2E tests.
+ *
+ * loginAs() bypasses the UI login form by obtaining a token directly from the
+ * API and injecting it into localStorage — the same keys the Auth service uses.
+ * Use the UI sign-in flow in auth.spec.js; use loginAs() everywhere else.
+ */
+
+
+/**
+ * Obtain an access token via the password grant and inject it into the page's
+ * localStorage so Angular's Auth service treats the session as authenticated.
+ *
+ * @param {import('@playwright/test').Page} page
+ * @param {string} username
+ * @param {string} password
+ */
+async function loginAs(page, username, password) {
+ const baseURL = process.env.E2E_BASE_URL || 'http://localhost:4200';
+
+ const resp = await page.request.post(`${baseURL}/api/oauth/authorize`, {
+ form: {
+ grant_type: 'password',
+ client_id: 'webapp',
+ username,
+ password,
+ scope: [
+ 'plugins', 'plugins:search', 'plugin:card', 'plugin:star',
+ 'plugin:download', 'tags', 'tag', 'authors', 'author', 'version',
+ 'message', 'user', 'user:externalaccounts', 'user:apps',
+ 'plugin:submit', 'users:search',
+ ].join(' '),
+ },
+ });
+
+ if (!resp.ok()) {
+ const body = await resp.text();
+ throw new Error(
+ `Auth request failed (${resp.status()}): ${body}`
+ );
+ }
+
+ const { access_token, refresh_token, expires_in } = await resp.json();
+
+ await page.evaluate(
+ ([token, refresh, expiresAt]) => {
+ localStorage.setItem('access_token', token);
+ localStorage.setItem('refresh_token', refresh);
+ localStorage.setItem('access_token_expires_at', expiresAt);
+ localStorage.setItem('authed', 'true');
+ },
+ [access_token, refresh_token, String(Math.floor(Date.now() / 1000) + expires_in)],
+ );
+
+ // Angular's $httpProvider.defaults.headers is set in .config() which runs
+ // only once at bootstrap. Reload so Angular re-bootstraps and picks up the
+ // token from localStorage before any test navigation occurs.
+ await page.reload();
+ await waitForAppReady(page);
+}
+
+/**
+ * Wait until the app has finished loading its initial data (no pending network
+ * requests for 500 ms). Works regardless of frontend framework.
+ *
+ * @param {import('@playwright/test').Page} page
+ */
+async function waitForAppReady(page) {
+ await page.waitForLoadState('networkidle');
+}
+
+module.exports = { loginAs, waitForAppReady };
diff --git a/frontend/e2e/homepage.spec.js b/frontend/e2e/homepage.spec.js
new file mode 100644
index 00000000..99591798
--- /dev/null
+++ b/frontend/e2e/homepage.spec.js
@@ -0,0 +1,38 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { waitForAppReady } = require('./helpers');
+
+test.describe('F1 — Homepage', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await waitForAppReady(page);
+ });
+
+ test('page title identifies the site', async ({ page }) => {
+ await expect(page).toHaveTitle(/GLPi Plugins/i);
+ });
+
+ test('featured sections are visible', async ({ page }) => {
+ // The featured page shows four lists: Trending, New, Popular, Updated
+ for (const heading of ['Trending', 'New', 'Popular', 'Updated']) {
+ await expect(page.getByText(heading, { exact: true }).first()).toBeVisible();
+ }
+ });
+
+ test('each section contains at least one plugin name', async ({ page }) => {
+ // Seed contains active plugins — they should appear in each list
+ for (const testid of ['trending-plugin-item', 'new-plugin-item', 'popular-plugin-item', 'updated-plugin-item']) {
+ await expect(page.locator(`[data-testid="${testid}"]`).first()).toBeVisible();
+ }
+ });
+
+ test('clicking a plugin name navigates to the plugin detail page', async ({ page }) => {
+ const firstPlugin = page.locator('[data-testid="trending-plugin-item"]').first();
+ const name = (await firstPlugin.textContent()) ?? '';
+ await firstPlugin.click();
+ // URL changes to #/plugin/
+ await expect(page).toHaveURL(/#\/plugin\//);
+ // The plugin name appears in the detail header
+ await expect(page.locator('[data-testid="plugin-name"]').filter({ hasText: name.trim() })).toBeVisible();
+ });
+});
diff --git a/frontend/e2e/package-lock.json b/frontend/e2e/package-lock.json
new file mode 100644
index 00000000..45217324
--- /dev/null
+++ b/frontend/e2e/package-lock.json
@@ -0,0 +1,79 @@
+{
+ "name": "glpi-plugins-e2e",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "glpi-plugins-e2e",
+ "devDependencies": {
+ "@playwright/test": "^1.44.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/frontend/e2e/package.json b/frontend/e2e/package.json
new file mode 100644
index 00000000..37794887
--- /dev/null
+++ b/frontend/e2e/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "glpi-plugins-e2e",
+ "private": true,
+ "devDependencies": {
+ "@playwright/test": "^1.44.0"
+ },
+ "scripts": {
+ "test": "playwright test",
+ "test:headed": "playwright test --headed",
+ "test:ui": "playwright test --ui"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/frontend/e2e/panel.spec.js b/frontend/e2e/panel.spec.js
new file mode 100644
index 00000000..5f9f8716
--- /dev/null
+++ b/frontend/e2e/panel.spec.js
@@ -0,0 +1,82 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { loginAs, waitForAppReady } = require('./helpers');
+
+test.describe('F5 — User Panel', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await loginAs(page, 'testuser', 'Password1');
+ await page.goto('/#/panel');
+ await waitForAppReady(page);
+ });
+
+ test('authenticated user sees the panel', async ({ page }) => {
+ await expect(page.getByText('My informations')).toBeVisible();
+ });
+
+ test('plugin list shows the user\'s plugins', async ({ page }) => {
+ await expect(page.getByText('My plugins')).toBeVisible();
+ await expect(page.locator('[data-testid="panel-plugin-name"]').filter({ hasText: 'Fields' })).toBeVisible();
+ });
+
+ test('API keys section is reachable', async ({ page }) => {
+ await page.getByText('Manage API Keys', { exact: false }).click();
+ await waitForAppReady(page);
+ await expect(page).toHaveURL(/#\/panel\/apikeys/);
+ });
+});
+
+test.describe('F6 — Plugin Author Panel', () => {
+ const originalXmlUrl = 'http://api/tests/E2E/fixtures/fields.xml';
+ const updatedXmlUrl = 'http://api/tests/E2E/fixtures/fields-updated.xml';
+ // Note: 'http://api' relies on Docker Compose internal DNS — the PHP container
+ // reaches itself via the 'api' service hostname on the shared bridge network.
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await loginAs(page, 'testuser', 'Password1');
+ await page.goto('/#/panel/plugin/fields');
+ await waitForAppReady(page);
+ });
+
+ test('plugin name is shown in the panel header', async ({ page }) => {
+ await expect(page.locator('[data-testid="plugin-panel-header-name"]')).toHaveText('Fields');
+ });
+
+ test('XML URL field is editable and persists on save', async ({ page }) => {
+ const xmlInput = page.locator('[data-testid="plugin-xml-url-input"]');
+ await xmlInput.click();
+ await xmlInput.fill(updatedXmlUrl);
+ await expect(xmlInput).toHaveValue(updatedXmlUrl);
+
+ await page.getByRole('button', { name: /save/i }).click();
+
+ // Reload and verify the new value is still there
+ await page.reload();
+ await waitForAppReady(page);
+ await expect(xmlInput).toHaveValue(updatedXmlUrl);
+ });
+
+ test.afterAll(async ({ browser }) => {
+ // Reset the XML URL to its original seed value so subsequent test runs are clean.
+ const page = await browser.newPage();
+ await page.goto('/');
+ await loginAs(page, 'testuser', 'Password1');
+ await page.goto('/#/panel/plugin/fields');
+ await waitForAppReady(page);
+ const xmlInput = page.locator('[data-testid="plugin-xml-url-input"]');
+ await xmlInput.fill(originalXmlUrl);
+ await page.getByRole('button', { name: /save/i }).click();
+ await page.close();
+ });
+
+ test('Refresh XML button is visible for admin', async ({ page }) => {
+ await expect(page.getByRole('button', { name: /refresh xml file/i })).toBeVisible();
+ });
+
+ test('Refresh XML button triggers a response', async ({ page }) => {
+ await page.getByRole('button', { name: /refresh xml file/i }).click();
+ // After refresh, xml_errors list updates (may be empty or show errors)
+ await expect(page.locator('[data-testid="xml-errors"]')).toBeVisible();
+ });
+});
diff --git a/frontend/e2e/playwright.config.js b/frontend/e2e/playwright.config.js
new file mode 100644
index 00000000..07d3a3d0
--- /dev/null
+++ b/frontend/e2e/playwright.config.js
@@ -0,0 +1,39 @@
+// @ts-check
+const { defineConfig, devices } = require('@playwright/test');
+
+const path = require('path');
+const COMPOSE_FILE = path.resolve(__dirname, '../../docker-compose.e2e.yml');
+
+module.exports = defineConfig({
+ testDir: '.',
+ testMatch: '**/*.spec.js',
+
+ // Start the Docker stack automatically if E2E_BASE_URL is not already set
+ // (i.e. not pointing at an externally managed server).
+ webServer: process.env.E2E_BASE_URL ? undefined : {
+ command: `docker compose -f ${COMPOSE_FILE} up --build`,
+ url: 'http://localhost:4200',
+ timeout: 300_000, // frontend image build can take a few minutes
+ reuseExistingServer: true,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ timeout: 30_000,
+ expect: { timeout: 8_000 },
+ fullyParallel: false, // AngularJS SPA shares a single backend; run serially
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
+
+ use: {
+ baseURL: process.env.E2E_BASE_URL || 'http://localhost:4200',
+ trace: 'on-first-retry',
+ // The app uses hash-based routing (#/plugin/fields); no need for JS-aware navigation.
+ // Increase navigation timeout for the first load (Angular bootstrap + anon token fetch).
+ navigationTimeout: 15_000,
+ },
+
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ ],
+});
diff --git a/frontend/e2e/plugin.spec.js b/frontend/e2e/plugin.spec.js
new file mode 100644
index 00000000..11c72b43
--- /dev/null
+++ b/frontend/e2e/plugin.spec.js
@@ -0,0 +1,47 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { loginAs, waitForAppReady } = require('./helpers');
+
+test.describe('F2 — Plugin detail page', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/#/plugin/fields');
+ await waitForAppReady(page);
+ });
+
+ test('plugin name is visible in the header', async ({ page }) => {
+ await expect(page.locator('[data-testid="plugin-name"]')).toHaveText('Fields');
+ });
+
+ test('author name is present', async ({ page }) => {
+ await expect(page.locator('[data-testid="plugin-authors"]').first()).toContainText('Plugin Author');
+ });
+
+ test('at least one version compatibility badge is shown', async ({ page }) => {
+ await expect(page.locator('[data-testid="version-badge"]').first()).toBeVisible();
+ });
+
+ test('description tab content is rendered', async ({ page }) => {
+ await expect(page.locator('[data-testid="plugin-description"]').first()).toBeVisible();
+ });
+
+ test('watch button is hidden when not logged in', async ({ page }) => {
+ await expect(page.locator('[data-testid="watch-button"]')).toBeHidden();
+ });
+
+ test('watch button is visible and toggles state when logged in', async ({ page }) => {
+ await loginAs(page, 'testuser', 'Password1');
+ await page.reload();
+ await waitForAppReady(page);
+
+ const watchBtn = page.locator('[data-testid="watch-button"]');
+ await expect(watchBtn).toBeVisible();
+
+ // Click to watch — icon switches from eye to eye-slash
+ await watchBtn.click();
+ await expect(watchBtn.locator('.fa-eye-slash')).toBeVisible();
+
+ // Click again to unwatch
+ await watchBtn.click();
+ await expect(watchBtn.locator('.fa-eye')).toBeVisible();
+ });
+});
diff --git a/frontend/e2e/search.spec.js b/frontend/e2e/search.spec.js
new file mode 100644
index 00000000..88d8ef4d
--- /dev/null
+++ b/frontend/e2e/search.spec.js
@@ -0,0 +1,42 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { waitForAppReady } = require('./helpers');
+
+test.describe('F3 — Search', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await waitForAppReady(page);
+ });
+
+ test('typing a query shows matching results', async ({ page }) => {
+ const searchBox = page.getByLabel('Search');
+ await searchBox.fill('Fields');
+ await searchBox.press('Enter');
+
+ await waitForAppReady(page);
+ // Results list: each result has a plugin name link
+ const results = page.locator('[data-testid="search-result-name"]');
+ await expect(results.first()).toBeVisible();
+ await expect(results.first()).toContainText(/fields/i);
+ });
+
+ test('each result shows a plugin name linking to the detail page', async ({ page }) => {
+ const searchBox = page.getByLabel('Search');
+ await searchBox.fill('Fields');
+ await searchBox.press('Enter');
+
+ await waitForAppReady(page);
+ const link = page.locator('[data-testid="search-result-name"]').first();
+ await link.click();
+ await expect(page).toHaveURL(/#\/plugin\//);
+ });
+
+ test('a query with no matches shows the empty-state message', async ({ page }) => {
+ const searchBox = page.getByLabel('Search');
+ await searchBox.fill('xyznonexistentplugin');
+ await searchBox.press('Enter');
+
+ await waitForAppReady(page);
+ await expect(page.getByText('No result')).toBeVisible();
+ });
+});
diff --git a/specs/README.md b/specs/README.md
new file mode 100644
index 00000000..8a527c16
--- /dev/null
+++ b/specs/README.md
@@ -0,0 +1,47 @@
+# GLPI Plugin Directory — Modernization Specs
+
+This folder contains specifications for modernizing the GLPI Plugin Directory.
+The project consists of a **Slim 2 PHP REST API** and an **AngularJS 1 frontend**.
+
+## Modernization Roadmap
+
+| Step | Status | Description |
+|------|--------|-------------|
+| 1 | 🔄 In Progress | Add unit, functional, and E2E tests |
+| 2 | ⏳ Planned | Upgrade backend (PHP 8+, Slim 4, modern OAuth) |
+| 3 | ⏳ Planned | Migrate frontend to a modern framework (Vue 3 / React) |
+| 4 | ⏳ Planned | CI/CD pipeline, containerization |
+
+## Repository Layout
+
+```
+plugins/
+├── api/ # Slim 2 PHP backend
+│ ├── src/
+│ │ ├── core/ # Tool, DB, Mailer, BackgroundTasks, OAuthClient
+│ │ ├── endpoints/ # Route handlers (one file per resource)
+│ │ ├── models/ # Eloquent ORM models
+│ │ ├── exceptions/ # Custom exception hierarchy
+│ │ └── oauthserver/ # OAuth2 server implementation
+│ ├── mailtemplates/ # Twig email templates
+│ └── misc/ # Background task runner scripts
+├── frontend/ # AngularJS 1 SPA
+│ ├── app/
+│ │ ├── scripts/ # Controllers, services, directives, filters
+│ │ └── views/ # HTML templates
+│ └── test/ # Existing Karma/Jasmine specs
+└── specs/ # ← You are here
+ ├── api/
+ │ └── endpoints.md # Full API endpoint reference
+ └── testing/
+ ├── unit.md # Unit test specifications
+ ├── functional.md # Functional/integration test specifications
+ └── e2e.md # End-to-end test specifications
+```
+
+## Specs Index
+
+- [API Endpoint Reference](api/endpoints.md)
+- [Unit Test Specifications](testing/unit.md)
+- [Functional Test Specifications](testing/functional.md)
+- [E2E Test Specifications](testing/e2e.md)
diff --git a/specs/api/endpoints.md b/specs/api/endpoints.md
new file mode 100644
index 00000000..f9ee7119
--- /dev/null
+++ b/specs/api/endpoints.md
@@ -0,0 +1,1033 @@
+# API Endpoint Reference
+
+Base URL: `https:///api`
+
+## Authentication
+
+All authenticated endpoints require a Bearer token in the `Authorization` header:
+
+```
+Authorization: Bearer
+```
+
+Tokens are issued by `POST /oauth/authorize` using one of three OAuth2 grant types:
+- `password` — user login (username/password)
+- `client_credentials` — API app key
+- `refresh_token` — token renewal
+
+### Scopes
+
+| Scope | Description |
+|-------|-------------|
+| `plugins` | Browse plugin list |
+| `plugins:search` | Search plugins |
+| `plugin:card` | Read single plugin details |
+| `plugin:star` | Rate a plugin |
+| `plugin:submit` | Submit a new plugin |
+| `plugin:download` | Download a plugin |
+| `tags` | Browse tag list |
+| `tag` | Read single tag |
+| `authors` | Browse author list |
+| `author` | Read single author |
+| `version` | Filter by GLPI version |
+| `user` | Read/edit own profile |
+| `user:apps` | Manage own API apps |
+| `user:externalaccounts` | Manage linked OAuth accounts |
+| `users:search` | Search users |
+| `message` | Send contact message |
+
+### Pagination
+
+Paginated endpoints support range-based pagination via HTTP headers:
+
+**Request header:**
+```
+x-range: 0-14
+```
+
+**Response headers:**
+```
+accept-range: model 100
+content-range: 0-14/100
+```
+
+HTTP status `206 Partial Content` is returned for partial results, `200 OK` for the complete set.
+
+Default page size: **15 items**.
+
+### Language
+
+Include the `x-lang` header to get localized plugin descriptions:
+
+```
+x-lang: fr
+```
+
+Supported values: `en`, `fr`, `es`. Any other value falls back to `en`.
+
+---
+
+## OAuth & Authorization
+
+### `POST /oauth/authorize`
+
+Issue an access token.
+
+**No auth required.**
+
+**Request body (password grant):**
+```json
+{
+ "grant_type": "password",
+ "username": "john",
+ "password": "secret",
+ "client_id": "",
+ "client_secret": ""
+}
+```
+
+**Request body (refresh_token grant):**
+```json
+{
+ "grant_type": "refresh_token",
+ "refresh_token": "",
+ "client_id": "",
+ "client_secret": ""
+}
+```
+
+**Request body (client_credentials grant):**
+```json
+{
+ "grant_type": "client_credentials",
+ "client_id": "",
+ "client_secret": ""
+}
+```
+
+**Response `200`:**
+```json
+{
+ "access_token": "...",
+ "refresh_token": "...",
+ "expires_in": 3600,
+ "token_type": "Bearer"
+}
+```
+
+---
+
+### `GET /oauth/associate/:service`
+
+OAuth2 callback from an external provider (currently: `github`). Handles three flows:
+
+1. **New user** — creates a GLPI account from external account info, returns tokens
+2. **Link account** — links external account to the currently authenticated user (pass `access_token` via cookie)
+3. **Returning user** — logs in and returns tokens if external account is already linked
+
+**No auth required.** Returns an HTML page that posts a `window.postMessage` with the result.
+
+**Path parameter:** `service` = `github`
+
+**Response data (in postMessage payload):**
+```json
+{
+ "access_token": "...",
+ "refresh_token": "...",
+ "access_token_expires_in": 3600,
+ "account_created": true,
+ "external_account_linked": true
+}
+```
+
+---
+
+### `GET /oauth/available_emails`
+
+Returns all email addresses available through the user's linked external accounts.
+
+**Scopes required:** `user`
+
+**Response `200`:**
+```json
+[
+ { "email": "user@example.com", "service": "github" }
+]
+```
+
+---
+
+## Users
+
+### `POST /user`
+
+Register a new account. Sends a confirmation email to the provided address.
+
+**No auth required.**
+
+**Request body:**
+```json
+{
+ "username": "john",
+ "email": "john@example.com",
+ "password": "secret123",
+ "realname": "John Doe",
+ "location": "Paris",
+ "website": "https://example.com"
+}
+```
+
+| Field | Required | Validation |
+|-------|----------|------------|
+| `username` | Yes | 4–28 chars, alphanumeric only |
+| `email` | Yes | Valid email format, unique |
+| `password` | Yes | Must pass `User::isValidPassword()` |
+| `realname` | No | 4+ chars, alphanumeric + spaces |
+| `location` | No | Non-empty string |
+| `website` | No | Valid URL |
+
+**Response `200`:** empty body (email sent)
+
+**Errors:**
+- `400 InvalidField` — invalid username/email/password format
+- `400 UnavailableName` — username or email already taken
+
+---
+
+### `GET /user`
+
+Get the current authenticated user's profile.
+
+**Scopes required:** `user`
+
+**Response `200`:**
+```json
+{
+ "id": 1,
+ "username": "john",
+ "email": "john@example.com",
+ "realname": "John Doe",
+ "location": "Paris",
+ "website": "https://example.com",
+ "gravatar": "",
+ "active": true
+}
+```
+
+---
+
+### `PUT /user`
+
+Edit the current user's profile. All fields are optional.
+
+**Scopes required:** `user`
+
+**Request body:**
+```json
+{
+ "email": "newemail@example.com",
+ "password": "newpassword",
+ "realname": "New Name",
+ "website": "https://new-site.com"
+}
+```
+
+Note: changing `email` is only allowed if that address is verified through a linked external account.
+
+**Response `200`:** updated user object
+
+---
+
+### `POST /user/delete`
+
+Delete the current user's account. Requires password confirmation.
+
+**Scopes required:** `user`
+
+**Request body:**
+```json
+{ "password": "current_password" }
+```
+
+**Response `200`:** empty body
+
+**Errors:**
+- `401 InvalidCredentials` — wrong password
+
+---
+
+### `GET /user/validatemail/:token`
+
+Validates the user's email address using the token from the confirmation email. Activates the account and returns an access token.
+
+**No auth required.**
+
+**Path parameter:** `token` — validation token from email
+
+**Response `200`:**
+```json
+{
+ "access_token": "...",
+ "refresh_token": "...",
+ "expires_in": 3600
+}
+```
+
+**Errors:**
+- `400 InvalidValidationToken` — token not found
+
+---
+
+### `POST /user/sendpasswordresetlink`
+
+Sends a password reset link to the provided email address.
+
+**No auth required.**
+
+**Request body:**
+```json
+{ "email": "john@example.com" }
+```
+
+**Response `200`:** empty body
+
+**Errors:**
+- `400 InvalidField` — missing/invalid email
+- `404 AccountNotFound` — no account with that email
+
+---
+
+### `PUT /user/password`
+
+Reset password using a token received by email.
+
+**No auth required.**
+
+**Request body:**
+```json
+{
+ "token": "",
+ "password": "new_password"
+}
+```
+
+**Response `200`:** empty body
+
+**Errors:**
+- `400 WrongPasswordResetToken` — token missing or not found
+- `400 InvalidField` — invalid password
+
+---
+
+### `GET /user/plugins`
+
+List plugins the current user has permissions on (active plugins only).
+
+**Scopes required:** `user`, `plugins`
+
+**Response `200`:** array of plugin objects
+
+---
+
+### `GET /user/watchs`
+
+List plugin keys that the current user is watching.
+
+**Scopes required:** `user`, `plugins`
+
+**Response `200`:**
+```json
+["myplugin", "anotherplugin"]
+```
+
+---
+
+### `POST /user/watchs`
+
+Start watching a plugin.
+
+**Scopes required:** `user`, `plugins`
+
+**Request body:**
+```json
+{ "plugin_key": "myplugin" }
+```
+
+**Response `200`:** empty body
+
+**Errors:**
+- `400 InvalidField` — missing plugin_key
+- `404 ResourceNotFound` — plugin not found
+- `400 AlreadyWatched` — already watching this plugin
+
+---
+
+### `DELETE /user/watchs/:key`
+
+Stop watching a plugin.
+
+**Scopes required:** `user`, `plugins`
+
+**Path parameter:** `key` — plugin key
+
+**Response `200`:** empty body
+**Response `404`:** plugin not found or not watching
+
+---
+
+### `POST /user/search`
+
+Search users by username, realname, or exact email.
+
+**Scopes required:** `users:search`
+
+**Request body:**
+```json
+{ "search": "john" }
+```
+
+**Response `200`:**
+```json
+[
+ { "username": "john", "realname": "John Doe" }
+]
+```
+
+---
+
+### `GET /user/external_accounts`
+
+List all external OAuth accounts linked to the current user.
+
+**Scopes required:** `user:externalaccounts`
+
+**Response `200`:**
+```json
+[
+ { "id": 1, "service": "github", "external_user_id": "12345" }
+]
+```
+
+---
+
+### `DELETE /user/external_accounts/:id`
+
+Unlink an external OAuth account.
+
+**Scopes required:** `user:externalaccounts`
+
+**Path parameter:** `id` — external account ID
+
+**Response `200`:** empty body
+
+**Errors:**
+- `401 NoCredentialsLeft` — cannot remove last external account when no password is set
+
+---
+
+## User Apps (API Keys)
+
+### `GET /user/apps`
+
+List all API applications created by the current user.
+
+**Scopes required:** `user`, `user:apps`
+
+**Response `200`:** array of app objects
+
+---
+
+### `GET /user/apps/:id`
+
+Get details of a specific app.
+
+**Scopes required:** `user`, `user:apps`
+
+**Response `200`:**
+```json
+{
+ "id": 1,
+ "name": "My App",
+ "homepage_url": "https://myapp.com",
+ "description": "A great app",
+ "client_id": "...",
+ "secret": "..."
+}
+```
+
+**Errors:**
+- `404 ResourceNotFound` — app not found
+
+---
+
+### `POST /user/apps`
+
+Create a new API application. Generates a `client_id` and `secret` automatically.
+
+**Scopes required:** `user`, `user:apps`
+
+**Request body:**
+```json
+{
+ "name": "My App",
+ "homepage_url": "https://myapp.com",
+ "description": "A great app"
+}
+```
+
+| Field | Required | Validation |
+|-------|----------|------------|
+| `name` | Yes | Must pass `App::isValidName()`, unique per user |
+| `homepage_url` | No | Valid URL |
+| `description` | No | Must pass `App::isValidDescription()` |
+
+**Errors:**
+- `400 InvalidField` — invalid name/url/description
+- `400 UnavailableName` — app name already taken
+
+---
+
+### `PUT /user/apps/:id`
+
+Update an existing app.
+
+**Scopes required:** `user`, `user:apps`
+
+**Request body:** same optional fields as `POST /user/apps`
+
+**Response `200`:** updated app object
+
+**Errors:**
+- `404 ResourceNotFound` — app not found
+
+---
+
+### `DELETE /user/apps/:id`
+
+Delete an API application.
+
+**Scopes required:** `user`, `user:apps`
+
+**Response `200`:** empty body
+
+**Errors:**
+- `404 ResourceNotFound` — app not found
+
+---
+
+## Plugins
+
+### `GET /plugin`
+
+List all active plugins, paginated. Sorted by default ordering.
+
+**Scopes required:** `plugins`
+
+**Response `206/200`:** paginated array of plugin objects with authors, versions, descriptions
+
+---
+
+### `POST /plugin`
+
+Submit a new plugin for review.
+
+**Scopes required:** `plugin:submit`
+
+**Request body:**
+```json
+{
+ "plugin_url": "https://example.com/plugin.xml",
+ "recaptcha_response": ""
+}
+```
+
+The XML at `plugin_url` is fetched and validated. The plugin `key` must be unique.
+
+**Response `200`:**
+```json
+{ "success": true }
+```
+
+**Errors:**
+- `400 InvalidRecaptcha` — reCAPTCHA failed
+- `400 InvalidField` — missing or invalid plugin_url
+- `400 UnavailableName` — XML URL or plugin key already exists
+- `400 InvalidXML` — XML unreachable or invalid
+
+---
+
+### `GET /plugin/new`
+
+Most recently added plugins, paginated.
+
+**Scopes required:** `plugins`
+
+**Response `206/200`:** paginated array of plugin objects
+
+---
+
+### `GET /plugin/popular`
+
+Most downloaded plugins, paginated.
+
+**Scopes required:** `plugins`
+
+---
+
+### `GET /plugin/trending`
+
+Trending plugins (most downloaded in the last 2 weeks), paginated.
+
+**Scopes required:** `plugins`
+
+---
+
+### `GET /plugin/updated`
+
+Most recently updated plugins, paginated.
+
+**Scopes required:** `plugins`
+
+---
+
+### `GET /plugin/rss_new`
+
+RSS feed of the 30 newest plugins.
+
+**No auth required.** Returns RSS XML.
+
+---
+
+### `GET /plugin/rss_updated`
+
+RSS feed of the 30 most recently updated plugins.
+
+**No auth required.** Returns RSS XML.
+
+---
+
+### `POST /plugin/star`
+
+Rate a plugin.
+
+**Scopes required:** `plugin:star`
+
+**Request body:**
+```json
+{
+ "plugin_id": 42,
+ "note": 4
+}
+```
+
+**Response `200`:**
+```json
+{ "new_average": 4.2 }
+```
+
+**Errors:**
+- `400` — missing or non-numeric `plugin_id` or `note`
+- `400` — plugin does not exist
+
+---
+
+### `GET /plugin/:key`
+
+Get full details for a single active plugin.
+
+**Scopes required:** `plugin:card`
+
+**Path parameter:** `key` — unique plugin key (e.g. `fields`)
+
+**Response `200`:**
+```json
+{
+ "id": 1,
+ "key": "fields",
+ "name": "Additional Fields",
+ "logo_url": "...",
+ "xml_url": "...",
+ "download_count": 15000,
+ "note": 4.5,
+ "nb_votes": 120,
+ "watched": false,
+ "descriptions": [...],
+ "authors": [...],
+ "versions": [...],
+ "screenshots": [...],
+ "tags": [...],
+ "langs": [...]
+}
+```
+
+**Errors:**
+- `404 ResourceNotFound` — plugin not found or inactive
+
+---
+
+### `GET /plugin/:key/download`
+
+Track a download and redirect to the plugin's download URL (HTTP 301).
+
+**No auth required.**
+
+When `Accept: application/json` header is set, tracking happens but no redirect is issued.
+
+---
+
+### `GET /plugin/:key/permissions`
+
+List users who have permissions on the plugin.
+
+**Scopes required:** `user`, `plugin:card`
+
+**Auth requirement:** caller must have `admin` flag on the plugin.
+
+**Response `200`:** array of user/permission objects
+
+---
+
+### `POST /plugin/:key/permissions`
+
+Grant a user access to the plugin.
+
+**Scopes required:** `user`, `plugin:card`
+
+**Auth requirement:** caller must have `admin` flag on the plugin.
+
+**Request body:**
+```json
+{ "username": "jane" }
+```
+
+**Errors:**
+- `400 InvalidField` — missing username
+- `404 ResourceNotFound` — user not found
+- `400 RightAlreadyExist` — user already has a permission entry
+
+---
+
+### `DELETE /plugin/:key/permissions/:username`
+
+Remove a user's permission on the plugin.
+
+**Scopes required:** `user`, `plugin:card`
+
+**Auth requirement:** caller must be `admin`, unless removing their own (non-admin) permission.
+
+**Errors:**
+- `400 RightDoesntExist` — user has no permission on this plugin
+- `401 CannotDeleteAdmin` — cannot remove an admin's permission
+
+---
+
+### `PATCH /plugin/:key/permissions/:username`
+
+Modify a specific permission flag for a user on a plugin.
+
+**Scopes required:** `user`, `plugin:card`
+
+**Auth requirement:** caller must have `admin` flag on the plugin.
+
+**Request body:**
+```json
+{
+ "right": "allowed_refresh_xml",
+ "set": true
+}
+```
+
+| `right` values |
+|----------------|
+| `allowed_refresh_xml` |
+| `allowed_change_xml_url` |
+| `allowed_notifications` |
+
+**Errors:**
+- `400 InvalidField` — invalid `right` or `set` value
+
+---
+
+### `POST /plugin/:key/refresh_xml`
+
+Re-fetch and update the plugin data from its XML URL.
+
+**Scopes required:** `user`, `plugin:card`
+
+**Auth requirement:** caller must have `admin` or `allowed_refresh_xml` flag.
+
+**Response `200`:**
+```json
+{
+ "errors": [],
+ "xml_state": { ... }
+}
+```
+
+**Errors in response body (not HTTP errors):**
+- XML URL unreachable
+- XML parse error
+- XML field validation errors (collected in array)
+
+---
+
+## Panel (Author Mode)
+
+### `GET /panel/plugin/:key`
+
+Author dashboard view of a plugin. Includes tags and stub statistics.
+
+**Scopes required:** `plugin:card`, `user`
+
+**Auth requirement:** caller must have `admin`, `allowed_refresh_xml`, or `allowed_change_xml_url` flag.
+
+**Response `200`:**
+```json
+{
+ "card": { ...plugin object... },
+ "tags": [...],
+ "statistics": {
+ "current_monthly_downloads": 500,
+ "current_weekly_downloads": 250
+ }
+}
+```
+
+---
+
+### `POST /panel/plugin/:key`
+
+Update the plugin's XML URL.
+
+**Scopes required:** `user`, `plugin:card`
+
+**Auth requirement:** caller must have `admin` or `allowed_change_xml_url` flag.
+
+**Request body:**
+```json
+{ "xml_url": "https://example.com/new-plugin.xml" }
+```
+
+The new URL is validated:
+1. Must be a valid URL
+2. Must be fetchable over HTTP
+3. XML must be valid and parseable
+4. Plugin `key` in XML must match the existing key
+5. All existing authors must still be present in the new XML
+
+**Response `200`:** empty body
+
+---
+
+## Authors
+
+### `GET /author`
+
+List all authors who have contributed to at least one plugin, paginated.
+
+**Scopes required:** `authors`
+
+---
+
+### `GET /author/top`
+
+List top authors (all, including non-contributors), paginated.
+
+**Scopes required:** `authors`
+
+---
+
+### `GET /author/:id`
+
+Get details for a specific author. Includes gravatar hash if linked to a user account.
+
+**Scopes required:** `author`
+
+**Path parameter:** `id` — author ID (integer)
+
+**Response `200`:**
+```json
+{
+ "id": 1,
+ "name": "John Doe",
+ "plugin_count": 5,
+ "username": "john",
+ "gravatar": ""
+}
+```
+
+---
+
+### `GET /author/:id/plugin`
+
+List plugins by a specific author, paginated.
+
+**Scopes required:** `author`, `plugins`
+
+---
+
+### `POST /claimauthorship`
+
+Send a request to admins to claim authorship of a plugin author record.
+
+**Scopes required:** `user`
+
+**Request body:**
+```json
+{
+ "author": "John Doe",
+ "recaptcha_response": ""
+}
+```
+
+**Response `200`:** empty body (email sent to admins)
+
+**Errors:**
+- `400 InvalidRecaptcha` — reCAPTCHA failed
+- `400 InvalidField` — missing/invalid author name
+- `404 ResourceNotFound` — author name not found
+
+---
+
+## Tags
+
+### `GET /tags`
+
+List all tags ordered by usage count, paginated. Automatically falls back to `en` if no tags in requested language.
+
+**Scopes required:** `tags`
+
+**Response `206/200`:** paginated array of tag objects
+
+---
+
+### `GET /tags/top`
+
+Same as `GET /tags`. Returns top tags by plugin count.
+
+**Scopes required:** `tags`
+
+---
+
+### `GET /tags/:id`
+
+Get a single tag by key.
+
+**Scopes required:** `tag`
+
+**Path parameter:** `id` — tag key (string, e.g. `inventory`)
+
+**Response `200`:**
+```json
+{ "id": 1, "key": "inventory", "tag": "Inventory", "plugin_count": 12 }
+```
+
+**Errors:**
+- `404 ResourceNotFound` — tag key not found
+
+---
+
+### `GET /tags/:id/plugin`
+
+List active plugins associated with a tag, paginated.
+
+**Scopes required:** `tag`, `plugins`
+
+---
+
+## Search
+
+### `POST /search`
+
+Full-text search across plugin names, keys, and descriptions.
+
+**Scopes required:** `plugins:search`
+
+**Request body:**
+```json
+{ "query_string": "inventory" }
+```
+
+Minimum length: **2 characters**.
+
+Results are ordered by `download_count DESC`, `note DESC`, `name ASC`.
+
+**Response `206/200`:** paginated array of plugin objects
+
+**Errors:**
+- `400` — query_string missing or too short
+
+---
+
+## Versions
+
+### `GET /version/:version/plugin`
+
+List plugins compatible with a given GLPI version, paginated.
+
+**Scopes required:** `version`, `plugins`
+
+**Path parameter:** `version` — GLPI version string (e.g. `9.5`, `10.0`)
+
+**Response `206/200`:** paginated array of plugin objects
+
+---
+
+## Messages
+
+### `POST /message`
+
+Send a contact message to the site administrators. Saved to DB and emailed.
+
+**Scopes required:** `message`
+
+**Request body:**
+```json
+{
+ "recaptcha_response": "",
+ "contact": {
+ "firstname": "John",
+ "lastname": "Doe",
+ "email": "john@example.com",
+ "subject": "Question about a plugin",
+ "message": "Hello, I have a question..."
+ }
+}
+```
+
+| Field | Max length |
+|-------|-----------|
+| `firstname` | 45 chars |
+| `lastname` | 45 chars |
+| `subject` | 280 chars |
+| `message` | 16000 chars |
+
+**Response `200`:**
+```json
+{ "success": true }
+```
+
+**Errors:**
+- `400 InvalidRecaptcha` — reCAPTCHA failed
+- `400 MissingField` — required contact field missing
+- `400 InvalidField` — field validation failed
+
+---
+
+## Error Response Format
+
+All errors return JSON with an HTTP status ≥ 400:
+
+```json
+{
+ "error": "RESOURCE_NOT_FOUND(type=Plugin, key=myplugin)"
+}
+```
+
+All errors use a single `error` field containing the string representation of the exception. Extra context (resource type, field name, etc.) is encoded inline in that string.
+
+Common HTTP status codes used:
+- `400 Bad Request` — invalid input
+- `401 Unauthorized` — missing or invalid token, or insufficient scopes/permissions
+- `404 Not Found` — resource does not exist
+- `500 Internal Server Error` — unexpected server error
diff --git a/specs/testing/e2e.md b/specs/testing/e2e.md
new file mode 100644
index 00000000..d210b4e4
--- /dev/null
+++ b/specs/testing/e2e.md
@@ -0,0 +1,152 @@
+# End-to-End Test Specifications
+
+E2E tests exercise the **full stack** through a real browser. They are the slowest
+layer but give the highest confidence before a release.
+
+## Stack
+
+| Tool | Purpose |
+|------|---------|
+| [Playwright](https://playwright.dev/) | Browser automation |
+| Docker Compose | Full stack (API + DB + frontend) |
+
+**Suggested location:** `frontend/e2e/`
+
+---
+
+## Environment Setup
+
+```yaml
+# docker-compose.e2e.yml
+services:
+ mysql:
+ image: mysql:8.0
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: glpi_plugins_e2e
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ api:
+ # Reuses the same Apache/PHP 7.4 image as the functional test stack
+ build:
+ context: .
+ dockerfile: .docker/test/api/Dockerfile
+ volumes:
+ - ./api:/var/www/api
+ environment:
+ APP_CONFIG_FILE: /var/www/api/config.e2e.php
+ TEST_DB_HOST: mysql
+ TEST_DB_NAME: glpi_plugins_e2e
+ TEST_DB_USER: root
+ TEST_DB_PASS: root
+ depends_on:
+ mysql:
+ condition: service_healthy
+
+ frontend:
+ # Builds the AngularJS SPA (grunt build) and serves dist/ statically
+ build:
+ context: .
+ dockerfile: .docker/e2e/frontend/Dockerfile
+ ports:
+ - "4200:80"
+ depends_on:
+ - api
+```
+
+`config.e2e.php` mirrors `config.php` but points to the `glpi_plugins_e2e` database.
+
+Run: `docker compose -f docker-compose.e2e.yml up -d`
+
+---
+
+## Frontend E2E Test Suites (Playwright)
+
+### Selector strategy
+
+Target **content and semantics**, not UI framework internals.
+The frontend is currently AngularJS 1 but will be replaced; CSS classes,
+`ng-*` attributes, and component structure will change. Tests must not.
+
+Preferred selectors, in order:
+
+1. **Visible text / labels** — `getByText('Sign in')`, `getByLabel('Password')`
+2. **ARIA roles** — `getByRole('button', { name: 'Watch' })`, `getByRole('navigation')`
+3. **Semantic HTML** — ``, `