Files
verdande/web/e2e/app.spec.js
T

220 lines
8.6 KiB
JavaScript
Raw Normal View History

import { test, expect } from '@playwright/test';
import { USER } from './user.js';
/**
* The interface the way the iOS app runs it: from a different origin than the
* server, with no cookie, its session as a bearer token.
*
* The real app is served from capacitor://localhost, which a test browser cannot
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
* the loopback address, so it is this same server, but to the browser it is a
* different origin, and that difference is the whole thing under test. The server
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
*
* bypassCSP because the server's policy is written for its own pages
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
* Without this the browser would block exactly the cross-origin calls the test
* exists to make, and the failure would read as the app not working.
*/
const PORT = 8097;
const SERVER = `http://localhost:${PORT}`;
test.use({
baseURL: `http://app.localhost:${PORT}`,
bypassCSP: true,
storageState: { cookies: [], origins: [] }
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.Capacitor = { isNativePlatform: () => true };
});
});
/** The app's session, read the way the app reads it. */
const storedToken = (page) =>
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
await page.goto('/');
// First the address — and an address with nothing behind it is said to be so,
// rather than leading to a sign-in form that cannot succeed.
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
await page.getByRole('button', { name: 'Fortsæt' }).click();
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
await page.getByLabel(/Serveradresse/).fill(SERVER);
await page.getByRole('button', { name: 'Fortsæt' }).click();
// The sign-in form, saying where it signs in to.
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// Signed in by token, and by nothing else: no session cookie on either origin.
const token = await storedToken(page);
expect(token).not.toBe('');
const cookies = await page.context().cookies();
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
// Live sync over a ticketed socket: a project made behind the interface's back
// — straight to the API, as another device would — shows up in the sidebar
// without a reload. Nothing but the socket can have put it there.
const name = `Fra en anden enhed ${Date.now()}`;
const status = await page.evaluate(
async ({ server, token, name }) => {
const res = await fetch(`${server}/api/v1/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return res.status;
},
{ server: SERVER, token, name }
);
expect(status).toBe(201);
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
// A picture from the server, which an <img> cannot fetch with a token. It is
// shown from a blob, and the path it came from is kept for the note to save.
const path = await page.evaluate(
async ({ server, token }) => {
const auth = { Authorization: `Bearer ${token}` };
const note = await fetch(`${server}/api/v1/notes`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: '# Billede fra appen' })
}).then((r) => r.json());
// A one-pixel PNG.
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
);
const form = new FormData();
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
method: 'POST',
headers: auth,
body: form
}).then((r) => r.json());
const img = document.createElement('img');
img.id = 'appbillede';
img.setAttribute('src', saved.url);
document.body.append(img);
return saved.url;
},
{ server: SERVER, token }
);
const img = page.locator('#appbillede');
await expect(img).toHaveAttribute('data-src', path);
await expect(img).toHaveAttribute('src', /^blob:/);
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
// Signing out takes the token off the phone, and keeps the server.
await page.evaluate(() => document.getElementById('appbillede')?.remove());
// Two taps: the first asks, the second signs out.
await page.getByRole('button', { name: /Log ud/ }).click();
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
expect(await storedToken(page)).toBe('');
// And the token is dead on the server too, not just forgotten here.
const after = await page.evaluate(
async ({ server, token }) =>
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
.status,
{ server: SERVER, token }
);
expect(after).toBe(401);
});
/**
* Signs in the way the app does, outside the page, and hands the page the server
* and the session as the app would have stored them.
*/
async function signedInApp(page) {
const origin = { Origin: `http://app.localhost:${PORT}` };
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
method: 'POST',
headers: { ...origin, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: USER.email,
password: USER.password,
client: 'app',
device_name: 'Prøve-iPhone'
})
});
const { session_token: token } = await res.json();
expect(token).toBeTruthy();
await page.addInitScript(
({ server, token }) => {
localStorage.setItem('verdande.app.server', server);
localStorage.setItem('verdande.app.session', token);
},
{ server: SERVER, token }
);
const call = async (method, path, body) => {
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
if (body instanceof FormData) init.body = body;
else if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const r = await fetch(`${SERVER}/api/v1${path}`, init);
return r.status === 204 ? null : r.json();
};
return call;
}
/**
* The picture in a note is shown from a blob made on the phone. Editing the note
* turns the page back into markdown, and what it must write is the path the
* picture came from — a blob address saved into a note is a picture that is gone
* the next time anybody opens it, on any device.
*/
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
page
}) => {
const call = await signedInApp(page);
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
'base64'
);
const form = new FormData();
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
await call('PATCH', `/notes/${note.id}`, {
body: `# Billednote fra appen\n\n![](${saved.url})\n\nTekst under billedet`
});
await page.goto('/noter?note=' + note.id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const img = ed.locator('img');
await expect(img).toHaveAttribute('src', /^blob:/);
await expect(img).toHaveAttribute('data-src', saved.url);
// Touch the note so it saves, and wait for the save itself.
const patched = page.waitForResponse(
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
);
await ed.getByText('Tekst under billedet').click();
await page.keyboard.press('End');
await page.keyboard.type(' og mere');
await patched;
const after = await call('GET', `/notes/${note.id}`);
expect(after.body).toContain('og mere');
expect(after.body).toContain(`](${saved.url})`);
expect(after.body).not.toContain('blob:');
});