Files
yucca/restic-api/test/app.e2e-spec.ts
T
Paul Makles 0b99068f23 feat: initial commit (#27)
* feat: initial commit (mise, nest, restic-api)

* ci: should use new nested tasks

* chore: use absolute imports

* chore: format

* fix: absolute imports for jest

* feat: REST API impl. with in-memory storage

* feat: s3 storage backing (wip)

* fix: use Range in S3 get object

* feat: validate path parameters

* chore: clean up checks

* chore: remove TODO

* docs: add descriptions to mise commands

* feat: JWT auth

* feat: load secrets from env (validate w. zod)

* chore: set default compose variables

* chore: add docker commands to mise

* fix: don't eat JWT errors

* fix: obfuscate s3 errors

* chore: validate auth schema

* feat: commit errors.ts

* fix: return partial status if Range provided

* chore: split jwt/payload validation errors

* fix: check name is a sha256 hash

* feat: streaming download

* feat: streaming upload

* feat: WORM / write once support

* fix: don't obscure other S3 errors for checkBucket

* refactor: more concise error types

* test: service unit tests for app/auth; e2e for storage

* chore: prettier ignore .dev folder

* feat: interceptor for Restic JSON routes

* test: app e2e tests (wip)

* test: partial content response

* chore: lint

* refactor: use class-validator for auth dto

* refactor: clean up constants through project

* refactor: also update binary content type

* fix: incorrect Basic parsing

* fix: actually handle validation errors in auth dto

* chore: delete .gitignore

* fix: should use 403 not 405 in worm

* chore: expose minio console for debug

* feat: delete config route

* fix: error consistency with restic reference

* feat: validate sha256 on blob upload

* fix: use bad request exception for mismatch

* feat: initial work on full e2e test

* fix: respond with correct headers for Range
refactor: unify respond logic

* test: backup & restore e2e test

* feat: setup empty yucca-api project

* feat: setup kysely for yucca-api & db repository

* refactor: `utils.ts` -> `utils/s3.ts`

* fix: shut down database after e2e test

* chore: remove stray log

* test: refactor getObject test

* chore: add built wrapper for now

* chore: add e2e/ to mise task

* chore: format

* fix: esm jest

* feat: setup database migrator

* feat: minimal template for frontend project

* chore: add prettier-plugin-tailwindcss to workspace

* chore: configure @immich/ui

* feat: sdk for web, hook up sample

* chore: add @oazapfts/runtime dependency

* chore: ignore `yucca-sdk/src/fetch-client.ts` from eslint

* chore: format

* chore: move all CI check stages into mise config

* test: mock $env/dynamic/publish for ui library

* chore: format code

* fix: correct restic API port in e2e test

* chore: move tasks around

* chore: use restic-wrapper from npm

* chore: string

* test: comprehensive e2e tests for restic API

* test: cover new commands in e2e tests

* test: worm-case for prune

* feat: lingui for svelte

* chore: ensure check runs lingui

* chore: ignore locales from checks

* chore: format/lint

* chore: configure nix-ld for mise

* chore: accept any no. of arguments for docker tasks/e2e

* chore: different strategy for e2e clean up

* chore: configure Git Town

Signed-off-by: izzy <me@insrt.uk>

* chore: add default.nix for mise

Signed-off-by: izzy <me@insrt.uk>

* chore: lost changes from parent

Signed-off-by: izzy <me@insrt.uk>

* fix: use valid language code

---------

Signed-off-by: izzy <me@insrt.uk>
2026-01-27 13:46:14 +00:00

392 lines
13 KiB
TypeScript

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, randomUUID } from 'node:crypto';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
const makeAuthHeader = (token: string) => 'Basic ' + Buffer.from(`_:${token}`).toString('base64');
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
let repository: string;
let authHeader: string;
let wormAuthHeader: string;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
const jwtService = app.get(JwtService);
repository = randomUUID();
authHeader = makeAuthHeader(jwtService.sign({ user: randomUUID(), repository, writeOnce: false }));
wormAuthHeader = makeAuthHeader(jwtService.sign({ user: randomUUID(), repository, writeOnce: true }));
});
describe('POST /:path', () => {
it('fails if create is not true', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=false`)
.set('Authorization', authHeader)
.expect(400);
});
it('creates the repository', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
});
it('fails if repository already exists', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(409);
});
});
describe('DELETE /:path', () => {
it('returns not implemented', async () => {
await request(app.getHttpServer()).delete(`/${repository}`).set('Authorization', authHeader).expect(501);
});
});
describe('HEAD /:path/config', () => {
it('returns 404 if config does not exist', async () => {
await request(app.getHttpServer()).head(`/${repository}/config`).set('Authorization', authHeader).expect(404);
});
it('returns content-length if config exists', async () => {
const config = 'test-config-data';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(200);
await request(app.getHttpServer())
.head(`/${repository}/config`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Length', String(config.length));
});
});
describe('GET, POST /:path/config', () => {
it('saves and returns config data', async () => {
const config = 'test-config-data';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/config`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Type', 'application/octet-stream');
expect(response.body.toString()).toBe(config);
});
it('fails to overwrite config with worm', async () => {
const config = 'test-config-data';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', wormAuthHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(403);
});
});
describe('GET /:path/:type', () => {
it('returns 501 without restic v2 accept header', async () => {
await request(app.getHttpServer()).get(`/${repository}/data`).set('Authorization', authHeader).expect(501);
});
it('returns empty list when no blobs exist', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data`)
.set('Authorization', authHeader)
.set('Accept', 'application/vnd.x.restic.rest.v2')
.expect(200)
.expect('Content-Type', 'application/vnd.x.restic.rest.v2');
expect(JSON.parse(response.text)).toEqual([]);
});
it('returns list of blobs when they exist', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData));
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data`)
.set('Authorization', authHeader)
.set('Accept', 'application/vnd.x.restic.rest.v2')
.expect(200)
.expect('Content-Type', 'application/vnd.x.restic.rest.v2');
expect(JSON.parse(response.text)).toEqual([{ name: blobName, size: blobData.length }]);
});
});
describe('HEAD /:path/:type/:name', () => {
it('returns 404 if blob does not exist', async () => {
await request(app.getHttpServer())
.head(`/${repository}/data/${'a'.repeat(64)}`)
.set('Authorization', authHeader)
.expect(404);
});
it('returns content-length if blob exists', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.head(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Length', String(blobData.length));
});
});
describe('GET, POST /:path/:type/:name', () => {
it('saves and returns blob data', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Type', 'application/octet-stream');
expect(response.body.toString()).toBe(blobData);
});
it('returns partial content with range header', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Range', 'bytes=0-3')
.expect(206)
.expect('Content-Type', 'application/octet-stream');
expect(response.body.toString()).toBe('test');
});
it('fails to overwrite blob with worm', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', wormAuthHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(403);
});
it('fails to write blob with non-hash for name', async () => {
const blobData = 'test-blob-data';
const blobName = 'invalid-hash';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(400);
});
it('fails to write blob with non-matching hash for name', async () => {
const blobData = 'test-blob-data';
const blobName = 'a'.repeat(64);
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(400);
});
});
describe('DELETE /:path/:type/:name', () => {
it('deletes a blob', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.delete(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.head(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(404);
});
it('fails to delete blob with worm', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', wormAuthHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.delete(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.expect(403);
});
});
});