From cd0d53c39da3bcd1ccefeeb6c527974825de6ac5 Mon Sep 17 00:00:00 2001
From: Miguel Casqueira <miguel@balena.io>
Date: Fri, 30 Oct 2020 17:38:19 -0400
Subject: [PATCH] Add more test coverage for compose/images

Closes: #1492
Change-type: patch
Signed-off-by: Miguel Casqueira <miguel@balena.io>
---
 test/00-init.spec.ts              |  29 +--
 test/39-compose-images.spec.ts    | 133 ++++++++++
 test/data/compose-image-data.json | 408 ++++++++++++++++++++++++++++++
 test/lib/mocked-database.ts       |  68 +++++
 test/lib/mocked-dbus.ts           |  28 ++
 test/lib/mocked-dockerode.ts      |  28 ++
 6 files changed, 666 insertions(+), 28 deletions(-)
 create mode 100644 test/39-compose-images.spec.ts
 create mode 100644 test/data/compose-image-data.json
 create mode 100644 test/lib/mocked-database.ts
 create mode 100644 test/lib/mocked-dbus.ts

diff --git a/test/00-init.spec.ts b/test/00-init.spec.ts
index 3a663d37..ddf81ce7 100644
--- a/test/00-init.spec.ts
+++ b/test/00-init.spec.ts
@@ -6,9 +6,6 @@ process.env.DATABASE_PATH_2 = './test/data/database2.sqlite';
 process.env.DATABASE_PATH_3 = './test/data/database3.sqlite';
 process.env.LED_FILE = './test/data/led_file';
 
-import * as dbus from 'dbus';
-import { DBusError, DBusInterface } from 'dbus';
-import { stub } from 'sinon';
 import * as fs from 'fs';
 
 // Make sure they are no database files left over from
@@ -33,31 +30,7 @@ fs.writeFileSync(
 	fs.readFileSync('./test/data/testconfig.json'),
 );
 
-stub(dbus, 'getBus').returns({
-	getInterface: (
-		_serviceName: string,
-		_objectPath: string,
-		_interfaceName: string,
-		interfaceCb: (err: null | DBusError, iface: DBusInterface) => void,
-	) => {
-		interfaceCb(null, {
-			Get: (
-				_unitName: string,
-				_property: string,
-				getCb: (err: null | Error, value: unknown) => void,
-			) => {
-				getCb(null, 'this is the value');
-			},
-			GetUnit: (
-				_unitName: string,
-				getUnitCb: (err: null | Error, unitPath: string) => void,
-			) => {
-				getUnitCb(null, 'this is the unit path');
-			},
-		} as any);
-	},
-} as any);
-
+import './lib/mocked-dbus';
 import './lib/mocked-dockerode';
 import './lib/mocked-iptables';
 import './lib/mocked-event-tracker';
diff --git a/test/39-compose-images.spec.ts b/test/39-compose-images.spec.ts
new file mode 100644
index 00000000..8a63f6c7
--- /dev/null
+++ b/test/39-compose-images.spec.ts
@@ -0,0 +1,133 @@
+import * as _ from 'lodash';
+
+import { docker } from '../src/lib/docker-utils';
+import { expect } from './lib/chai-config';
+import * as Images from '../src/compose/images';
+import * as mockedDockerode from './lib/mocked-dockerode';
+import * as mockedDatabase from './lib/mocked-database';
+import * as db from '../src/db';
+import * as sampleImageData from './data/compose-image-data.json';
+
+describe('compose/images', () => {
+	before(() => {
+		mockedDatabase.create();
+	});
+
+	after(() => {
+		try {
+			mockedDatabase.restore();
+		} catch (e) {
+			/* noop */
+		}
+	});
+
+	afterEach(() => {
+		// Clear Dockerode actions recorded for each test
+		mockedDockerode.resetHistory();
+	});
+
+	it('Removes a legacy Image', async () => {
+		const images = sampleImageData['legacy-image'].dockerode;
+		const IMAGE_TO_REMOVE = sampleImageData['legacy-image'].remove;
+		const IMAGES_FROM_DB = sampleImageData['legacy-image'].database;
+		// Stub the database to return images we want
+		mockedDatabase.setImages(IMAGES_FROM_DB).stub();
+		// Perform the test with our specially crafted data
+		await mockedDockerode.testWithData({ images }, async () => {
+			// Check that our legacy image exists
+			await expect(docker.getImage(IMAGE_TO_REMOVE.name).inspect()).to
+				.eventually.not.be.undefined;
+			await expect(
+				db.models('image').select().where(IMAGE_TO_REMOVE),
+			).to.eventually.have.lengthOf(1);
+			// Check that docker has this Image
+			await expect(docker.getImage(IMAGE_TO_REMOVE.name).inspect()).to
+				.eventually.not.be.undefined;
+			// Now remove this image...
+			await Images.remove(IMAGE_TO_REMOVE);
+			// Check if it still exists!
+			await expect(docker.getImage(IMAGE_TO_REMOVE.name).inspect()).to
+				.eventually.be.undefined;
+			await expect(db.models('image').select().where(IMAGE_TO_REMOVE)).to
+				.eventually.be.empty;
+			// Check that docker remove was called once
+			const removeSteps = _(mockedDockerode.actions)
+				.pickBy({ name: 'remove' })
+				.map()
+				.value();
+			expect(removeSteps).to.have.lengthOf(1);
+		});
+	});
+
+	it('Removes a single Image', async () => {
+		const images = sampleImageData['single-image'].dockerode;
+		const IMAGE_TO_REMOVE = sampleImageData['single-image'].remove;
+		const IMAGES_FROM_DB = sampleImageData['single-image'].database;
+		// Stub the database to return images we want
+		mockedDatabase.setImages(IMAGES_FROM_DB).stub();
+		// Perform the test with our specially crafted data
+		await mockedDockerode.testWithData({ images }, async () => {
+			// Check that a single image is returned when given entire object
+			expect(
+				db.models('image').select().where(IMAGE_TO_REMOVE),
+			).to.eventually.have.lengthOf(1);
+			// Check that only one image with this dockerImageId exists in the db
+			expect(
+				db
+					.models('image')
+					.where({ dockerImageId: IMAGE_TO_REMOVE.dockerImageId })
+					.select(),
+			).to.eventually.have.lengthOf(1);
+			// Now remove this image...
+			await Images.remove(IMAGE_TO_REMOVE);
+			// Check that docker does not have this image
+			await expect(docker.getImage(IMAGE_TO_REMOVE.name).inspect()).to
+				.eventually.be.empty;
+			// Check that the database longer has this image
+			await expect(db.models('image').select().where(IMAGE_TO_REMOVE)).to
+				.eventually.be.empty;
+			// Check that docker remove was called once
+			const removeSteps = _(mockedDockerode.actions)
+				.pickBy({ name: 'remove' })
+				.map()
+				.value();
+			expect(removeSteps).to.have.lengthOf(1);
+		});
+	});
+
+	it('Removes an Image with digests', async () => {
+		const images = sampleImageData['image-with-digests'].dockerode;
+		const IMAGE_TO_REMOVE = sampleImageData['image-with-digests'].remove;
+		const IMAGES_FROM_DB = sampleImageData['image-with-digests'].database;
+		// Stub the database to return images we want
+		mockedDatabase.setImages(IMAGES_FROM_DB).stub();
+		// Perform the test with our specially crafted data
+		await mockedDockerode.testWithData({ images }, async () => {
+			// Check that a single image is returned when given entire object
+			expect(
+				db.models('image').select().where(IMAGE_TO_REMOVE),
+			).to.eventually.have.lengthOf(1);
+			// Check that multiple images with the same dockerImageId are returned
+			expect(
+				db
+					.models('image')
+					.where({ dockerImageId: IMAGE_TO_REMOVE.dockerImageId })
+					.select(),
+			).to.eventually.have.lengthOf(2);
+			// Now remove these image...
+			await Images.remove(IMAGE_TO_REMOVE);
+			// Check that docker does not have this image
+			await expect(docker.getImage(IMAGE_TO_REMOVE.name).inspect()).to
+				.eventually.be.empty;
+			// Check that the database no longer has this image
+			await expect(db.models('image').select().where(IMAGE_TO_REMOVE)).to
+				.eventually.be.empty;
+			// Check that docker remove was called twice
+			const removeSteps = _(mockedDockerode.actions)
+				.pickBy({ name: 'remove' })
+				.map()
+				.value();
+			expect(removeSteps).to.have.lengthOf(2);
+		});
+	});
+});
diff --git a/test/data/compose-image-data.json b/test/data/compose-image-data.json
new file mode 100644
index 00000000..41b382d2
--- /dev/null
+++ b/test/data/compose-image-data.json
@@ -0,0 +1,408 @@
+{
+  "legacy-image": {
+    "remove": {
+      "id": 246,
+      "name": "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+      "appId": 1658654,
+      "serviceId": 650325,
+      "serviceName": "app_1",
+      "imageId": 2693229,
+      "releaseId": 1524186,
+      "dependent": 0
+    },
+    "database": [
+      {
+        "id": 246,
+        "name": "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+        "appId": 1658654,
+        "serviceId": 650325,
+        "serviceName": "app_1",
+        "imageId": 2693229,
+        "releaseId": 1524186,
+        "dependent": 0
+      }
+    ],
+    "dockerode": {
+      "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582": {
+        "Containers": -1,
+        "Created": 1599492712,
+        "Id": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:72b80cbd3cc12de08d4adc9dec79916bf466031553f55b59c29e397829ea129f"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a:delta-ada9fbb57d90e61e"
+        ],
+        "SharedSize": -1,
+        "Size": 217024648,
+        "VirtualSize": 217024648
+      },
+      "livepush-supervisor:11.12.11": {
+        "Containers": -1,
+        "Created": 1599492599,
+        "Id": "sha256:db5af2c94366275d8e6d7ea3047f2405eab2f04a27f66843634a45958ef59f5a",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.livepush-image": "1",
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "sha256:4acb1652178e72c0b6143a08225e0df5ef74b338a0c9e2ca4cd261339f4f0431",
+        "RepoDigests": null,
+        "RepoTags": [
+          "livepush-supervisor:11.12.11"
+        ],
+        "SharedSize": -1,
+        "Size": 501352885,
+        "VirtualSize": 501352885
+      }
+    }
+  },
+  "single-image": {
+    "remove": {
+      "id": 246,
+      "name": "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+      "appId": 1658654,
+      "serviceId": 650325,
+      "serviceName": "app_1",
+      "imageId": 2693229,
+      "releaseId": 1524186,
+      "dependent": 0,
+      "dockerImageId": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7"
+    },
+    "database": [
+      {
+        "id": 246,
+        "name": "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+        "appId": 1658654,
+        "serviceId": 650325,
+        "serviceName": "app_1",
+        "imageId": 2693229,
+        "releaseId": 1524186,
+        "dependent": 0,
+        "dockerImageId": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7"
+      },
+      {
+        "id": 247,
+        "name": "registry2.balena-cloud.com/v2/902cf44eb0ed51675a0bf95a7bbf0c91@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+        "appId": 1658654,
+        "serviceId": 650331,
+        "serviceName": "app_2",
+        "imageId": 2693230,
+        "releaseId": 1524186,
+        "dependent": 0,
+        "dockerImageId": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc901234"
+      }
+    ],
+    "dockerode": {
+      "sha256:acf4069b3cf68d05dc8a2df0e511447927303ebef88f897f05cbad823f240d97": {
+        "Containers": -1,
+        "Created": 1599492964,
+        "Id": "sha256:acf4069b3cf68d05dc8a2df0e511447927303ebef88f897f05cbad823f240d97",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/772eca412ce780e860b988da6ea26ee0@sha256:7d5e55d6aac00b8504c5c360a3ee59677fc5a7324360f1f54df19d0bb17c2cfe",
+          "registry2.balena-cloud.com/v2/7f1290fa85b253936ebf6e0dbbd95875@sha256:107f63eb1bc2e9978f2a4bb5b095bc010dd91dd5a6a0a39a494e27ee8b396232"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/772eca412ce780e860b988da6ea26ee0:delta-ff3f0a82c404830b",
+          "registry2.balena-cloud.com/v2/7f1290fa85b253936ebf6e0dbbd95875:delta-79acb6d8bf4795f6"
+        ],
+        "SharedSize": -1,
+        "Size": 217024666,
+        "VirtualSize": 217024666
+      },
+      "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7": {
+        "Containers": -1,
+        "Created": 1599492712,
+        "Id": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:72b80cbd3cc12de08d4adc9dec79916bf466031553f55b59c29e397829ea129f"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a:delta-ada9fbb57d90e61e"
+        ],
+        "SharedSize": -1,
+        "Size": 217024648,
+        "VirtualSize": 217024648
+      },
+      "sha256:db5af2c94366275d8e6d7ea3047f2405eab2f04a27f66843634a45958ef59f5a": {
+        "Containers": -1,
+        "Created": 1599492599,
+        "Id": "sha256:db5af2c94366275d8e6d7ea3047f2405eab2f04a27f66843634a45958ef59f5a",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.livepush-image": "1",
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "sha256:4acb1652178e72c0b6143a08225e0df5ef74b338a0c9e2ca4cd261339f4f0431",
+        "RepoDigests": null,
+        "RepoTags": [
+          "livepush-supervisor:11.12.11"
+        ],
+        "SharedSize": -1,
+        "Size": 501352885,
+        "VirtualSize": 501352885
+      },
+      "sha256:272c54e27104cf6c2153538165dba3c29b58ae35837b47134fa55b53ddb61154": {
+        "Containers": -1,
+        "Created": 1599181227,
+        "Id": "sha256:272c54e27104cf6c2153538165dba3c29b58ae35837b47134fa55b53ddb61154",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/459bffd69101d788cc3e0722a012878a@sha256:3cc9231cf0e117585e53ebfa6bf9f75a9a4eaa371fb82c21ab9bca8fe0d5c3e3",
+          "registry2.balena-cloud.com/v2/b0cfe2b1e8c5ab3b6da23f0bd92045b4@sha256:ba5f6d1849c63c8d0f11f35fce694464240002d2c3732898935bf0fedf451063"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/459bffd69101d788cc3e0722a012878a:delta-80ed841a1d3fefa9",
+          "registry2.balena-cloud.com/v2/b0cfe2b1e8c5ab3b6da23f0bd92045b4:delta-532f970c60decb81"
+        ],
+        "SharedSize": -1,
+        "Size": 217024558,
+        "VirtualSize": 217024558
+      },
+      "sha256:25d6abae14f08de6b80f9d95003e674598738959a535a2f21be34c03675ebd02": {
+        "Containers": -1,
+        "Created": 1599146808,
+        "Id": "sha256:25d6abae14f08de6b80f9d95003e674598738959a535a2f21be34c03675ebd02",
+        "Labels": {
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "balena/aarch64-supervisor@sha256:e7e3b166e855f4c113a67bc528b4ef77408d05e280052f35452f3e2cd7b5322b"
+        ],
+        "RepoTags": [
+          "balena/aarch64-supervisor:v11.14.0"
+        ],
+        "SharedSize": -1,
+        "Size": 72305850,
+        "VirtualSize": 72305850
+      },
+      "sha256:60783b8688d395f9b4ce4b288d941244d1b0a4c43114ba980acd012ccffc6b53": {
+        "Containers": -1,
+        "Created": 1585746557,
+        "Id": "sha256:60783b8688d395f9b4ce4b288d941244d1b0a4c43114ba980acd012ccffc6b53",
+        "Labels": {
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "balenalib/aarch64-alpine-supervisor-base@sha256:6eb712fc797ff68f258d9032cf292c266cb9bd8be4cbdaaafeb5a8824bb104fd"
+        ],
+        "RepoTags": [
+          "balenalib/aarch64-alpine-supervisor-base:3.11"
+        ],
+        "SharedSize": -1,
+        "Size": 14503741,
+        "VirtualSize": 14503741
+      },
+      "sha256:a29f45ccde2ac0bde957b1277b1501f471960c8ca49f1588c6c885941640ae60": {
+        "Containers": -1,
+        "Created": 1578015959,
+        "Id": "sha256:a29f45ccde2ac0bde957b1277b1501f471960c8ca49f1588c6c885941640ae60",
+        "Labels": null,
+        "ParentId": "",
+        "RepoDigests": null,
+        "RepoTags": [
+          "balena-healthcheck-image:latest"
+        ],
+        "SharedSize": -1,
+        "Size": 9136,
+        "VirtualSize": 9136
+      }
+    }
+  },
+  "image-with-digests": {
+    "remove": {
+      "id": 246,
+      "name": "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+      "appId": 1658654,
+      "serviceId": 650325,
+      "serviceName": "app_1",
+      "imageId": 2693229,
+      "releaseId": 1524186,
+      "dependent": 0,
+      "dockerImageId": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7"
+    },
+    "database": [
+      {
+        "id": 246,
+        "name": "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+        "appId": 1658654,
+        "serviceId": 650325,
+        "serviceName": "app_1",
+        "imageId": 2693229,
+        "releaseId": 1524186,
+        "dependent": 0,
+        "dockerImageId": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7"
+      },
+      {
+        "id": 247,
+        "name": "registry2.balena-cloud.com/v2/902cf44eb0ed51675a0bf95a7bbf0c91@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582",
+        "appId": 1658654,
+        "serviceId": 650331,
+        "serviceName": "app_2",
+        "imageId": 2693230,
+        "releaseId": 1524186,
+        "dependent": 0,
+        "dockerImageId": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7"
+      }
+    ],
+    "dockerode": {
+      "sha256:acf4069b3cf68d05dc8a2df0e511447927303ebef88f897f05cbad823f240d97": {
+        "Containers": -1,
+        "Created": 1599492964,
+        "Id": "sha256:acf4069b3cf68d05dc8a2df0e511447927303ebef88f897f05cbad823f240d97",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/772eca412ce780e860b988da6ea26ee0@sha256:7d5e55d6aac00b8504c5c360a3ee59677fc5a7324360f1f54df19d0bb17c2cfe",
+          "registry2.balena-cloud.com/v2/7f1290fa85b253936ebf6e0dbbd95875@sha256:107f63eb1bc2e9978f2a4bb5b095bc010dd91dd5a6a0a39a494e27ee8b396232"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/772eca412ce780e860b988da6ea26ee0:delta-ff3f0a82c404830b",
+          "registry2.balena-cloud.com/v2/7f1290fa85b253936ebf6e0dbbd95875:delta-79acb6d8bf4795f6"
+        ],
+        "SharedSize": -1,
+        "Size": 217024666,
+        "VirtualSize": 217024666
+      },
+      "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7": {
+        "Containers": -1,
+        "Created": 1599492712,
+        "Id": "sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a@sha256:72b80cbd3cc12de08d4adc9dec79916bf466031553f55b59c29e397829ea129f"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/793f9296017bbfe026334820ab56bb3a:delta-ada9fbb57d90e61e"
+        ],
+        "SharedSize": -1,
+        "Size": 217024648,
+        "VirtualSize": 217024648
+      },
+      "sha256:db5af2c94366275d8e6d7ea3047f2405eab2f04a27f66843634a45958ef59f5a": {
+        "Containers": -1,
+        "Created": 1599492599,
+        "Id": "sha256:db5af2c94366275d8e6d7ea3047f2405eab2f04a27f66843634a45958ef59f5a",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.livepush-image": "1",
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "sha256:4acb1652178e72c0b6143a08225e0df5ef74b338a0c9e2ca4cd261339f4f0431",
+        "RepoDigests": null,
+        "RepoTags": [
+          "livepush-supervisor:11.12.11"
+        ],
+        "SharedSize": -1,
+        "Size": 501352885,
+        "VirtualSize": 501352885
+      },
+      "sha256:272c54e27104cf6c2153538165dba3c29b58ae35837b47134fa55b53ddb61154": {
+        "Containers": -1,
+        "Created": 1599181227,
+        "Id": "sha256:272c54e27104cf6c2153538165dba3c29b58ae35837b47134fa55b53ddb61154",
+        "Labels": {
+          "io.balena.architecture": "aarch64",
+          "io.balena.device-type": "jetson-tx2",
+          "io.balena.qemu.version": "4.0.0+balena2-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "registry2.balena-cloud.com/v2/459bffd69101d788cc3e0722a012878a@sha256:3cc9231cf0e117585e53ebfa6bf9f75a9a4eaa371fb82c21ab9bca8fe0d5c3e3",
+          "registry2.balena-cloud.com/v2/b0cfe2b1e8c5ab3b6da23f0bd92045b4@sha256:ba5f6d1849c63c8d0f11f35fce694464240002d2c3732898935bf0fedf451063"
+        ],
+        "RepoTags": [
+          "registry2.balena-cloud.com/v2/459bffd69101d788cc3e0722a012878a:delta-80ed841a1d3fefa9",
+          "registry2.balena-cloud.com/v2/b0cfe2b1e8c5ab3b6da23f0bd92045b4:delta-532f970c60decb81"
+        ],
+        "SharedSize": -1,
+        "Size": 217024558,
+        "VirtualSize": 217024558
+      },
+      "sha256:25d6abae14f08de6b80f9d95003e674598738959a535a2f21be34c03675ebd02": {
+        "Containers": -1,
+        "Created": 1599146808,
+        "Id": "sha256:25d6abae14f08de6b80f9d95003e674598738959a535a2f21be34c03675ebd02",
+        "Labels": {
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "balena/aarch64-supervisor@sha256:e7e3b166e855f4c113a67bc528b4ef77408d05e280052f35452f3e2cd7b5322b"
+        ],
+        "RepoTags": [
+          "balena/aarch64-supervisor:v11.14.0"
+        ],
+        "SharedSize": -1,
+        "Size": 72305850,
+        "VirtualSize": 72305850
+      },
+      "sha256:60783b8688d395f9b4ce4b288d941244d1b0a4c43114ba980acd012ccffc6b53": {
+        "Containers": -1,
+        "Created": 1585746557,
+        "Id": "sha256:60783b8688d395f9b4ce4b288d941244d1b0a4c43114ba980acd012ccffc6b53",
+        "Labels": {
+          "io.balena.qemu.version": "4.0.0+balena-aarch64"
+        },
+        "ParentId": "",
+        "RepoDigests": [
+          "balenalib/aarch64-alpine-supervisor-base@sha256:6eb712fc797ff68f258d9032cf292c266cb9bd8be4cbdaaafeb5a8824bb104fd"
+        ],
+        "RepoTags": [
+          "balenalib/aarch64-alpine-supervisor-base:3.11"
+        ],
+        "SharedSize": -1,
+        "Size": 14503741,
+        "VirtualSize": 14503741
+      },
+      "sha256:a29f45ccde2ac0bde957b1277b1501f471960c8ca49f1588c6c885941640ae60": {
+        "Containers": -1,
+        "Created": 1578015959,
+        "Id": "sha256:a29f45ccde2ac0bde957b1277b1501f471960c8ca49f1588c6c885941640ae60",
+        "Labels": null,
+        "ParentId": "",
+        "RepoDigests": null,
+        "RepoTags": [
+          "balena-healthcheck-image:latest"
+        ],
+        "SharedSize": -1,
+        "Size": 9136,
+        "VirtualSize": 9136
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/test/lib/mocked-database.ts b/test/lib/mocked-database.ts
new file mode 100644
index 00000000..38fd59be
--- /dev/null
+++ b/test/lib/mocked-database.ts
@@ -0,0 +1,68 @@
+import * as _ from 'lodash';
+import { SinonStub, stub } from 'sinon';
+import { QueryBuilder } from 'knex';
+
+import * as db from '../../src/db';
+import { Image } from '../../src/compose/images';
+
+let modelStub: SinonStub | null = null;
+// MOCKED MODELS
+const MOCKED_MODELS: Dictionary<QueryBuilder> = {};
+// MOCKED MODEL TYPES
+let MOCKED_IMAGES: Image[] = [];
+
+export function create(): SinonStub {
+	if (modelStub === null) {
+		modelStub = stub(db, 'models');
+		// Stub model requests to return our stubbed models
+		modelStub.callsFake((model: string) => {
+			return MOCKED_MODELS[model];
+		});
+	}
+	return modelStub;
+}
+
+export function restore(): void {
+	if (modelStub !== null) {
+		modelStub.restore();
+	}
+}
+
+export function setImages(images: Image[]) {
+	MOCKED_IMAGES = images;
+	return {
+		stub: stubImages,
+	};
+}
+
+function stubImages() {
+	// Set the functions for this model (add them as you need for your test cases)
+	MOCKED_MODELS['image'] = ({
+		select: () => {
+			return {
+				where: async (condition: Partial<Image>) =>
+					_(MOCKED_IMAGES).pickBy(condition).map().value(),
+			};
+		},
+		where: (condition: Partial<Image>) => {
+			return {
+				select: async () => _(MOCKED_IMAGES).pickBy(condition).map().value(),
+			};
+		},
+		del: () => {
+			return {
+				where: async (condition: Partial<Image>) => {
+					return _(MOCKED_IMAGES)
+						.pickBy(condition)
+						.map()
+						.value()
+						.forEach((val) => {
+							MOCKED_IMAGES = _.reject(MOCKED_IMAGES, {
+								id: val.id,
+							});
+						});
+				},
+			};
+		},
+	} as unknown) as QueryBuilder;
+}
diff --git a/test/lib/mocked-dbus.ts b/test/lib/mocked-dbus.ts
new file mode 100644
index 00000000..214b07a4
--- /dev/null
+++ b/test/lib/mocked-dbus.ts
@@ -0,0 +1,28 @@
+import * as dbus from 'dbus';
+import { DBusError, DBusInterface } from 'dbus';
+import { stub } from 'sinon';
+
+stub(dbus, 'getBus').returns({
+	getInterface: (
+		_serviceName: string,
+		_objectPath: string,
+		_interfaceName: string,
+		interfaceCb: (err: null | DBusError, iface: DBusInterface) => void,
+	) => {
+		interfaceCb(null, {
+			Get: (
+				_unitName: string,
+				_property: string,
+				getCb: (err: null | Error, value: unknown) => void,
+			) => {
+				getCb(null, 'this is the value');
+			},
+			GetUnit: (
+				_unitName: string,
+				getUnitCb: (err: null | Error, unitPath: string) => void,
+			) => {
+				getUnitCb(null, 'this is the unit path');
+			},
+		} as any);
+	},
+} as any);
diff --git a/test/lib/mocked-dockerode.ts b/test/lib/mocked-dockerode.ts
index 835ac4f5..50471510 100644
--- a/test/lib/mocked-dockerode.ts
+++ b/test/lib/mocked-dockerode.ts
@@ -15,6 +15,24 @@ export class NotFoundError extends TypedError {
 
 const overrides: Dictionary<(...args: any[]) => Resolvable<any>> = {};
 
+interface Action {
+	name: string;
+	parameters: Dictionary<any>;
+}
+
+export let actions: Action[] = [];
+
+export function resetHistory() {
+	actions = [];
+}
+
+function addAction(name: string, parameters: Dictionary<any>) {
+	actions.push({
+		name,
+		parameters,
+	});
+}
+
 type DockerodeFunction = keyof dockerode;
 for (const fn of Object.getOwnPropertyNames(dockerode.prototype)) {
 	if (
@@ -62,18 +80,28 @@ export interface TestData {
 function createMockedDockerode(data: TestData) {
 	const mockedDockerode = dockerode.prototype;
 	mockedDockerode.getNetwork = (id: string) => {
+		addAction('getNetwork', { id });
 		return {
 			inspect: async () => {
+				addAction('inspect', {});
 				return data.networks[id];
 			},
 		} as dockerode.Network;
 	};
 
 	mockedDockerode.getImage = (name: string) => {
+		addAction('getImage', { name });
 		return {
 			inspect: async () => {
+				addAction('inspect', {});
 				return data.images[name];
 			},
+			remove: async () => {
+				addAction('remove', {});
+				data.images = _.reject(data.images, {
+					name,
+				});
+			},
 		} as dockerode.Image;
 	};