Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@
"backgroundColor": "#0c0b15"
}
}
],
"expo-image",
[
"expo-image-picker",
{
"photosPermission": "SoftTrack uses your photo library to attach screenshots to issues.",
"cameraPermission": "SoftTrack uses the camera to attach a photo to an issue."
}
]
],
"experiments": {
Expand Down
74 changes: 64 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"expo": "~57.0.22",
"expo-blur": "~57.0.3",
"expo-constants": "~57.0.18",
"expo-document-picker": "~57.0.2",
"expo-file-system": "~57.0.7",
"expo-image": "~57.0.5",
"expo-image-picker": "~57.0.17",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.10",
"expo-router": "~57.0.21",
Expand Down
139 changes: 139 additions & 0 deletions src/api/__tests__/live-attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* @jest-environment node
*/
import Axios from 'axios';

Check warning on line 4 in src/api/__tests__/live-attachments.test.ts

View workflow job for this annotation

GitHub Actions / Mobile · lint + typecheck + test + bundle

Using exported name 'Axios' as identifier for default import

import { AXIOS_INSTANCE } from '@/api/client';
import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth';
import {
deleteAttachmentAttachmentsAttachmentIdDelete,
listIssueAttachmentsIssuesIssueIdAttachmentsGet,
} from '@/api/generated/endpoints/attachments/attachments';
import {
createIssueTeamsTeamIdIssuesPost,
deleteIssueIssuesIssueIdDelete,
} from '@/api/generated/endpoints/issues/issues';
import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams';
import type { AttachmentRead, TeamRead } from '@/api/generated/models';
import { setInstanceUrl } from '@/api/instance';
import { persistToken, getAccessToken } from '@/auth/session';

/**
* Attachments against a real instance.
*
* The acceptance criterion for #9 is that a photo taken on the phone lands on
* the issue and renders. A jest run has no camera, so a real PNG is built in
* memory and posted the same way the app posts one -- what is being checked is
* that multipart upload, the server's type rules, and authenticated reads of
* the bytes all line up.
*/
const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
const describeLive = LIVE_URL ? describe : describe.skip;

const RUN = Date.now().toString(36).slice(-5);

/** The smallest valid PNG: 1x1, and it really does start with the signature. */
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);

async function postFile(issueId: number, filename: string, bytes: Buffer, type: string) {
const form = new FormData();
form.append('file', new Blob([new Uint8Array(bytes)], { type }), filename);
const { data } = await AXIOS_INSTANCE.post<AttachmentRead>(
`/issues/${issueId}/attachments`,
form,
);
return data;
}

describeLive('attachments against a live instance', () => {
let team: TeamRead;
let issueId: number;

beforeAll(async () => {
await setInstanceUrl(LIVE_URL!);
const token = await loginAuthLoginPost({
username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev',
password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123',
});
await persistToken(token.access_token);
team = (await listMyTeamsTeamsGet())[0];
const issue = await createIssueTeamsTeamIdIssuesPost(team.id, {
title: `Attachments ${RUN}`,
});
issueId = issue.id;
});

afterAll(async () => {
await deleteIssueIssuesIssueIdDelete(issueId).catch(() => undefined);
});

it('accepts an image and marks it as one', async () => {
const attachment = await postFile(issueId, `shot-${RUN}.png`, PNG_1X1, 'image/png');

expect(attachment.is_image).toBe(true);
expect(attachment.content_type).toBe('image/png');
expect(attachment.size_bytes).toBe(PNG_1X1.length);
// is_image is what decides between rendering inline and offering to open.
expect(attachment.url).toContain(String(attachment.id));

const listed = await listIssueAttachmentsIssuesIssueIdAttachmentsGet(issueId);
expect(listed.map((a) => a.id)).toContain(attachment.id);

await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id);
});

it('serves the bytes only with the bearer token', async () => {
const attachment = await postFile(issueId, `auth-${RUN}.png`, PNG_1X1, 'image/png');
const base = LIVE_URL!;
const path = `/attachments/${attachment.id}/content`;

// With the header the app sends, which is why an image source carries it
// rather than relying on a plain URL.
const ok = await AXIOS_INSTANCE.get(path, { responseType: 'arraybuffer' });
expect(ok.status).toBe(200);
expect(Buffer.from(ok.data).subarray(0, 8)).toEqual(PNG_1X1.subarray(0, 8));

// And without it: a bare client, the way a plain <img src> would ask.
expect(getAccessToken()).not.toBeNull();
const anonymous = Axios.create({ baseURL: base });

Check warning on line 101 in src/api/__tests__/live-attachments.test.ts

View workflow job for this annotation

GitHub Actions / Mobile · lint + typecheck + test + bundle

Caution: `Axios` also has a named export `create`. Check if you meant to write `import {create} from 'axios'` instead
await expect(anonymous.get(path)).rejects.toMatchObject({
response: { status: 401 },
});

await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id);
});

it('refuses an SVG, which is why the client refuses it first', async () => {
// 415 for a type that is not on the list at all.
await expect(
postFile(issueId, `diagram-${RUN}.svg`, Buffer.from('<svg/>'), 'image/svg+xml'),
).rejects.toMatchObject({ response: { status: 415 } });
});

it('refuses a file whose bytes are not the image its name claims', async () => {
// "Screenshot of the bug" is the whole point, so a mislabelled file fails
// now with a clear error rather than later as a broken image.
// 422 rather than 415: the extension is allowed, the bytes are not what it
// claims, and the server says exactly that.
await expect(
postFile(issueId, `fake-${RUN}.png`, Buffer.from('not a png at all'), 'image/png'),
).rejects.toMatchObject({
response: { status: 422, data: { detail: expect.stringContaining('not a valid PNG') } },
});
});

it('takes a non-image and does not mark it as one', async () => {
const attachment = await postFile(
issueId,
`notes-${RUN}.txt`,
Buffer.from('a log line'),
'text/plain',
);
expect(attachment.is_image).toBe(false);
expect(attachment.content_type).toBe('text/plain');
await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id);
});
});
58 changes: 58 additions & 0 deletions src/attachments/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { contentTypeFor, formatBytes, MAX_BYTES, rejectionFor } from '@/attachments/rules';
import { filenameFrom } from '@/attachments/upload';

describe('contentTypeFor', () => {
it('accepts what the server accepts', () => {
expect(contentTypeFor('shot.PNG')).toBe('image/png');
expect(contentTypeFor('notes.md')).toBe('text/markdown');
expect(contentTypeFor('fix.patch')).toBe('text/plain');
expect(contentTypeFor('clip.mov')).toBe('video/quicktime');
});

it('refuses what it does not', () => {
expect(contentTypeFor('page.html')).toBeNull();
expect(contentTypeFor('noextension')).toBeNull();
});
});

describe('rejectionFor', () => {
it('says why an SVG is refused rather than just refusing it', () => {
// The server leaves SVG out deliberately: it is a document that can carry
// script. A generic "not allowed" would read as an oversight.
const rejection = rejectionFor('diagram.svg', 1000);
expect(rejection?.reason).toBe('type');
expect(rejection?.message).toContain('script');
});

it('names the file and the limit when it is too big', () => {
const rejection = rejectionFor('huge.zip', MAX_BYTES + 1);
expect(rejection?.reason).toBe('size');
expect(rejection?.message).toContain('huge.zip');
expect(rejection?.message).toContain('25.0 MB');
});

it('passes a file the server would take', () => {
expect(rejectionFor('shot.png', 500_000)).toBeNull();
// Unknown size is not a reason to refuse; the server has the final say.
expect(rejectionFor('shot.png', undefined)).toBeNull();
});
});

describe('filenameFrom', () => {
it('keeps the extension the server validates on', () => {
expect(filenameFrom('file:///var/tmp/ABC123.jpg')).toBe('ABC123.jpg');
expect(filenameFrom('file:///tmp/my%20shot.png')).toBe('my shot.png');
});

it('invents a name with an extension when the camera gives none', () => {
expect(filenameFrom('file:///var/tmp/ABC123')).toMatch(/^upload-\d+\.jpg$/);
});
});

describe('formatBytes', () => {
it('reads the way a person would say it', () => {
expect(formatBytes(512)).toBe('512 B');
expect(formatBytes(2048)).toBe('2 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
});
});
Loading
Loading