PageRenderTime 27ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 0ms

/plugins/techdocs-node/src/stages/publish/openStackSwift.test.ts

https://github.com/backstage/backstage
TypeScript | 482 lines | 401 code | 61 blank | 20 comment | 15 complexity | aec5a1cb8ac436e8f2a5d5abbfb318fb MD5 | raw file
Possible License(s): Apache-2.0, MIT, BSD-3-Clause, MPL-2.0-no-copyleft-exception
  1. /*
  2. * Copyright 2020 The Backstage Authors
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. import { getVoidLogger } from '@backstage/backend-common';
  17. import {
  18. Entity,
  19. CompoundEntityRef,
  20. DEFAULT_NAMESPACE,
  21. } from '@backstage/catalog-model';
  22. import { ConfigReader } from '@backstage/config';
  23. import express from 'express';
  24. import request from 'supertest';
  25. import mockFs from 'mock-fs';
  26. import fs from 'fs-extra';
  27. import path from 'path';
  28. import { OpenStackSwiftPublish } from './openStackSwift';
  29. import { PublisherBase, TechDocsMetadata } from './types';
  30. import { storageRootDir } from '../../testUtils/StorageFilesMock';
  31. import { Stream, Readable } from 'stream';
  32. jest.mock('@trendyol-js/openstack-swift-sdk', () => {
  33. const {
  34. ContainerMetaResponse,
  35. DownloadResponse,
  36. NotFound,
  37. ObjectMetaResponse,
  38. UploadResponse,
  39. }: typeof import('@trendyol-js/openstack-swift-sdk') = jest.requireActual(
  40. '@trendyol-js/openstack-swift-sdk',
  41. );
  42. const checkFileExists = async (Key: string): Promise<boolean> => {
  43. // Key will always have / as file separator irrespective of OS since cloud providers expects /.
  44. // Normalize Key to OS specific path before checking if file exists.
  45. const filePath = path.join(storageRootDir, Key);
  46. try {
  47. await fs.access(filePath, fs.constants.F_OK);
  48. return true;
  49. } catch (err) {
  50. return false;
  51. }
  52. };
  53. const streamToBuffer = (stream: Stream | Readable): Promise<Buffer> => {
  54. return new Promise((resolve, reject) => {
  55. try {
  56. const chunks: any[] = [];
  57. stream.on('data', chunk => chunks.push(chunk));
  58. stream.on('error', reject);
  59. stream.on('end', () => resolve(Buffer.concat(chunks)));
  60. } catch (e) {
  61. throw new Error(`Unable to parse the response data ${e.message}`);
  62. }
  63. });
  64. };
  65. return {
  66. __esModule: true,
  67. SwiftClient: class {
  68. async getMetadata(_containerName: string, file: string) {
  69. const fileExists = await checkFileExists(file);
  70. if (fileExists) {
  71. return new ObjectMetaResponse({
  72. fullPath: file,
  73. });
  74. }
  75. return new NotFound();
  76. }
  77. async getContainerMetadata(containerName: string) {
  78. if (containerName === 'mock') {
  79. return new ContainerMetaResponse({
  80. size: 10,
  81. });
  82. }
  83. return new NotFound();
  84. }
  85. async upload(
  86. _containerName: string,
  87. destination: string,
  88. stream: Readable,
  89. ) {
  90. try {
  91. const filePath = path.join(storageRootDir, destination);
  92. const fileBuffer = await streamToBuffer(stream);
  93. await fs.writeFile(filePath, fileBuffer);
  94. const fileExists = await checkFileExists(destination);
  95. if (fileExists) {
  96. return new UploadResponse(filePath);
  97. }
  98. const errorMessage = `Unable to upload file(s) to OpenStack Swift.`;
  99. throw new Error(errorMessage);
  100. } catch (error) {
  101. const errorMessage = `Unable to upload file(s) to OpenStack Swift. ${error}`;
  102. throw new Error(errorMessage);
  103. }
  104. }
  105. async download(_containerName: string, file: string) {
  106. const filePath = path.join(storageRootDir, file);
  107. const fileExists = await checkFileExists(file);
  108. if (!fileExists) {
  109. return new NotFound();
  110. }
  111. return new DownloadResponse([], fs.createReadStream(filePath));
  112. }
  113. },
  114. };
  115. });
  116. const createMockEntity = (annotations = {}): Entity => {
  117. return {
  118. apiVersion: 'version',
  119. kind: 'TestKind',
  120. metadata: {
  121. name: 'test-component-name',
  122. namespace: 'test-namespace',
  123. annotations: {
  124. ...annotations,
  125. },
  126. },
  127. };
  128. };
  129. const createMockEntityName = (): CompoundEntityRef => ({
  130. kind: 'TestKind',
  131. name: 'test-component-name',
  132. namespace: 'test-namespace',
  133. });
  134. const getEntityRootDir = (entity: Entity) => {
  135. const {
  136. kind,
  137. metadata: { namespace, name },
  138. } = entity;
  139. return path.join(storageRootDir, namespace || DEFAULT_NAMESPACE, kind, name);
  140. };
  141. const getPosixEntityRootDir = (entity: Entity) => {
  142. const {
  143. kind,
  144. metadata: { namespace, name },
  145. } = entity;
  146. return path.posix.join(
  147. '/rootDir',
  148. namespace || DEFAULT_NAMESPACE,
  149. kind,
  150. name,
  151. );
  152. };
  153. const logger = getVoidLogger();
  154. let publisher: PublisherBase;
  155. beforeEach(() => {
  156. mockFs.restore();
  157. const mockConfig = new ConfigReader({
  158. techdocs: {
  159. publisher: {
  160. type: 'openStackSwift',
  161. openStackSwift: {
  162. credentials: {
  163. id: 'mockid',
  164. secret: 'verystrongsecret',
  165. },
  166. authUrl: 'mockauthurl',
  167. swiftUrl: 'mockSwiftUrl',
  168. containerName: 'mock',
  169. },
  170. },
  171. },
  172. });
  173. publisher = OpenStackSwiftPublish.fromConfig(mockConfig, logger);
  174. });
  175. describe('OpenStackSwiftPublish', () => {
  176. describe('getReadiness', () => {
  177. it('should validate correct config', async () => {
  178. expect(await publisher.getReadiness()).toEqual({
  179. isAvailable: true,
  180. });
  181. });
  182. it('should reject incorrect config', async () => {
  183. const mockConfig = new ConfigReader({
  184. techdocs: {
  185. publisher: {
  186. type: 'openStackSwift',
  187. openStackSwift: {
  188. credentials: {
  189. id: 'mockId',
  190. secret: 'mockSecret',
  191. },
  192. authUrl: 'mockauthurl',
  193. swiftUrl: 'mockSwiftUrl',
  194. containerName: 'errorBucket',
  195. },
  196. },
  197. },
  198. });
  199. const errorPublisher = OpenStackSwiftPublish.fromConfig(
  200. mockConfig,
  201. logger,
  202. );
  203. expect(await errorPublisher.getReadiness()).toEqual({
  204. isAvailable: false,
  205. });
  206. });
  207. });
  208. describe('publish', () => {
  209. beforeEach(() => {
  210. const entity = createMockEntity();
  211. const entityRootDir = getEntityRootDir(entity);
  212. mockFs({
  213. [entityRootDir]: {
  214. 'index.html': '',
  215. '404.html': '',
  216. assets: {
  217. 'main.css': '',
  218. },
  219. },
  220. });
  221. });
  222. afterEach(() => {
  223. mockFs.restore();
  224. });
  225. it('should publish a directory', async () => {
  226. const entity = createMockEntity();
  227. const entityRootDir = getEntityRootDir(entity);
  228. expect(
  229. await publisher.publish({
  230. entity,
  231. directory: entityRootDir,
  232. }),
  233. ).toMatchObject({
  234. objects: expect.arrayContaining([
  235. 'test-namespace/TestKind/test-component-name/404.html',
  236. `test-namespace/TestKind/test-component-name/index.html`,
  237. `test-namespace/TestKind/test-component-name/assets/main.css`,
  238. ]),
  239. });
  240. });
  241. it('should fail to publish a directory', async () => {
  242. const wrongPathToGeneratedDirectory = path.join(
  243. storageRootDir,
  244. 'wrong',
  245. 'path',
  246. 'to',
  247. 'generatedDirectory',
  248. );
  249. const entity = createMockEntity();
  250. await expect(
  251. publisher.publish({
  252. entity,
  253. directory: wrongPathToGeneratedDirectory,
  254. }),
  255. ).rejects.toThrowError();
  256. const fails = publisher.publish({
  257. entity,
  258. directory: wrongPathToGeneratedDirectory,
  259. });
  260. // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error
  261. // Issue reported https://github.com/tschaub/mock-fs/issues/118
  262. await expect(fails).rejects.toMatchObject({
  263. message: expect.stringContaining(
  264. `Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`,
  265. ),
  266. });
  267. await expect(fails).rejects.toMatchObject({
  268. message: expect.stringContaining(wrongPathToGeneratedDirectory),
  269. });
  270. mockFs.restore();
  271. });
  272. });
  273. describe('hasDocsBeenGenerated', () => {
  274. it('should return true if docs has been generated', async () => {
  275. const entity = createMockEntity();
  276. const entityRootDir = getEntityRootDir(entity);
  277. mockFs({
  278. [entityRootDir]: {
  279. 'index.html': 'file-content',
  280. },
  281. });
  282. expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
  283. mockFs.restore();
  284. });
  285. it('should return false if docs has not been generated', async () => {
  286. const entity = createMockEntity();
  287. expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false);
  288. });
  289. });
  290. describe('fetchTechDocsMetadata', () => {
  291. it('should return tech docs metadata', async () => {
  292. const entityNameMock = createMockEntityName();
  293. const entity = createMockEntity();
  294. const entityRootDir = getEntityRootDir(entity);
  295. mockFs({
  296. [entityRootDir]: {
  297. 'techdocs_metadata.json':
  298. '{"site_name": "backstage", "site_description": "site_content", "etag": "etag", "build_timestamp": 612741599}',
  299. },
  300. });
  301. const expectedMetadata: TechDocsMetadata = {
  302. site_name: 'backstage',
  303. site_description: 'site_content',
  304. etag: 'etag',
  305. build_timestamp: 612741599,
  306. };
  307. expect(
  308. await publisher.fetchTechDocsMetadata(entityNameMock),
  309. ).toStrictEqual(expectedMetadata);
  310. mockFs.restore();
  311. });
  312. it('should return tech docs metadata when json encoded with single quotes', async () => {
  313. const entityNameMock = createMockEntityName();
  314. const entity = createMockEntity();
  315. const entityRootDir = getEntityRootDir(entity);
  316. mockFs({
  317. [entityRootDir]: {
  318. 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag', 'build_timestamp': 612741599}`,
  319. },
  320. });
  321. const expectedMetadata: TechDocsMetadata = {
  322. site_name: 'backstage',
  323. site_description: 'site_content',
  324. etag: 'etag',
  325. build_timestamp: 612741599,
  326. };
  327. expect(
  328. await publisher.fetchTechDocsMetadata(entityNameMock),
  329. ).toStrictEqual(expectedMetadata);
  330. mockFs.restore();
  331. });
  332. it('should return an error if the techdocs_metadata.json file is not present', async () => {
  333. const entityNameMock = createMockEntityName();
  334. const entity = createMockEntity();
  335. const entityRootDir = getPosixEntityRootDir(entity);
  336. const fails = publisher.fetchTechDocsMetadata(entityNameMock);
  337. await expect(fails).rejects.toMatchObject({
  338. message: `TechDocs metadata fetch failed, The file ${path.posix.join(
  339. entityRootDir,
  340. 'techdocs_metadata.json',
  341. )} does not exist !`,
  342. });
  343. });
  344. });
  345. describe('docsRouter', () => {
  346. let app: express.Express;
  347. const entity = createMockEntity();
  348. const entityRootDir = getEntityRootDir(entity);
  349. beforeEach(() => {
  350. app = express().use(publisher.docsRouter());
  351. mockFs.restore();
  352. mockFs({
  353. [entityRootDir]: {
  354. html: {
  355. 'unsafe.html': '<html></html>',
  356. },
  357. img: {
  358. 'unsafe.svg': '<svg></svg>',
  359. 'with spaces.png': 'found it',
  360. },
  361. 'some folder': {
  362. 'also with spaces.js': 'found it too',
  363. },
  364. },
  365. });
  366. });
  367. afterEach(() => {
  368. mockFs.restore();
  369. });
  370. it('should pass expected object path to bucket', async () => {
  371. const {
  372. kind,
  373. metadata: { namespace, name },
  374. } = entity;
  375. // Ensures leading slash is trimmed and encoded path is decoded.
  376. const pngResponse = await request(app).get(
  377. `/${namespace}/${kind}/${name}/img/with%20spaces.png`,
  378. );
  379. expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
  380. 'found it',
  381. );
  382. const jsResponse = await request(app).get(
  383. `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`,
  384. );
  385. expect(jsResponse.text).toEqual('found it too');
  386. });
  387. it('should pass text/plain content-type for unsafe types', async () => {
  388. const {
  389. kind,
  390. metadata: { namespace, name },
  391. } = entity;
  392. const htmlResponse = await request(app).get(
  393. `/${namespace}/${kind}/${name}/html/unsafe.html`,
  394. );
  395. expect(htmlResponse.text).toEqual('<html></html>');
  396. expect(htmlResponse.header).toMatchObject({
  397. 'content-type': 'text/plain; charset=utf-8',
  398. });
  399. const svgResponse = await request(app).get(
  400. `/${namespace}/${kind}/${name}/img/unsafe.svg`,
  401. );
  402. expect(svgResponse.text).toEqual('<svg></svg>');
  403. expect(svgResponse.header).toMatchObject({
  404. 'content-type': 'text/plain; charset=utf-8',
  405. });
  406. });
  407. it('should return 404 if file is not found', async () => {
  408. const {
  409. kind,
  410. metadata: { namespace, name },
  411. } = entity;
  412. const response = await request(app).get(
  413. `/${namespace}/${kind}/${name}/not-found.html`,
  414. );
  415. expect(response.status).toBe(404);
  416. expect(Buffer.from(response.text).toString('utf8')).toEqual(
  417. 'File Not Found',
  418. );
  419. });
  420. });
  421. });