diff --git a/.editorconfig b/.editorconfig index 2c24a0ad..1422ed50 100644 --- a/.editorconfig +++ b/.editorconfig @@ -35,6 +35,8 @@ indent_size = 4 [{*.js,*.css,*.ts,*.tsx,*.jsx,*.graphql,*.ns}] indent_style = space indent_size = 2 +[core/**/*.js] # CouchDB design document JavaScript files get special treatment +indent_size = 4 [syslog-ng.conf] indent_style = tab diff --git a/client/src/main/java/me/retrodaredevil/solarthing/program/couchdb/CouchDbSetupMain.java b/client/src/main/java/me/retrodaredevil/solarthing/program/couchdb/CouchDbSetupMain.java index 78d2f1ba..832cfc73 100644 --- a/client/src/main/java/me/retrodaredevil/solarthing/program/couchdb/CouchDbSetupMain.java +++ b/client/src/main/java/me/retrodaredevil/solarthing/program/couchdb/CouchDbSetupMain.java @@ -104,7 +104,10 @@ private void addPacketsDesign(SolarThingDatabaseType databaseType, CouchDbDataba out.println("This database will have the simpleAllDocs view"); design.addSimpleAllDocsView(); } - if (databaseType.needsReadonlyValidateFunction()) { + if (databaseType == SolarThingDatabaseType.OPEN) { + out.println("This database will not allow editing of documents after they are uploaded."); + design.setSolarThingOpenValidate(); + } else if (databaseType.needsReadonlyValidateFunction()) { out.println("This database will be readonly"); design.setReadonlyAuth(); } diff --git a/client/src/test/java/me/retrodaredevil/solarthing/integration/DatabaseOpenUploadOnlyTest.java b/client/src/test/java/me/retrodaredevil/solarthing/integration/DatabaseOpenUploadOnlyTest.java new file mode 100644 index 00000000..09b27514 --- /dev/null +++ b/client/src/test/java/me/retrodaredevil/solarthing/integration/DatabaseOpenUploadOnlyTest.java @@ -0,0 +1,64 @@ +package me.retrodaredevil.solarthing.integration; + +import me.retrodaredevil.couchdbjava.CouchDbInstance; +import me.retrodaredevil.couchdbjava.exception.CouchDbException; +import me.retrodaredevil.couchdbjava.exception.CouchDbUnauthorizedException; +import me.retrodaredevil.solarthing.SolarThingDatabaseType; +import me.retrodaredevil.solarthing.database.SolarThingDatabase; +import me.retrodaredevil.solarthing.database.UpdateToken; +import me.retrodaredevil.solarthing.database.couchdb.CouchDbSolarThingDatabase; +import me.retrodaredevil.solarthing.database.exception.SolarThingDatabaseException; +import me.retrodaredevil.solarthing.misc.device.CelsiusCpuTemperaturePacket; +import me.retrodaredevil.solarthing.packets.collection.PacketCollection; +import me.retrodaredevil.solarthing.packets.collection.PacketCollectionIdGenerator; +import me.retrodaredevil.solarthing.packets.collection.PacketCollections; +import me.retrodaredevil.solarthing.packets.instance.InstanceFragmentIndicatorPackets; +import me.retrodaredevil.solarthing.packets.instance.InstanceSourcePackets; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +@Tag("integration") +public class DatabaseOpenUploadOnlyTest { + + private static PacketCollection createSimplePacketCollection() { + // Note that in a production environment, we would make sure that the packets in a given packet collection + // actually belong in a particular database, but for testing we don't care. (A CpuTemperaturePacket shouldn't end up in the events database) + return PacketCollections.createFromPackets( + Instant.now(), + Arrays.asList( + new CelsiusCpuTemperaturePacket(null, 20.2f, null), + InstanceSourcePackets.create("default"), + InstanceFragmentIndicatorPackets.create(1) + ), + PacketCollectionIdGenerator.Defaults.UNIQUE_GENERATOR, + ZoneId.systemDefault() + ); + } + + + @ParameterizedTest + @MethodSource("me.retrodaredevil.solarthing.integration.DatabaseService#all") + void test(DatabaseService databaseService) throws CouchDbException, SolarThingDatabaseException { + IntegrationSetup.setup(IntegrationUtil.createCouchDbInstance(databaseService, IntegrationUtil.DEFAULT_ADMIN_AUTH)); + + // Uploader user + CouchDbInstance uploaderInstance = IntegrationUtil.createCouchDbInstance(databaseService, IntegrationUtil.getAuthFor(SolarThingDatabaseType.UserType.UPLOADER)); + SolarThingDatabase database = CouchDbSolarThingDatabase.create(uploaderInstance); + + PacketCollection packetCollection = createSimplePacketCollection(); + UpdateToken updateToken = database.getOpenDatabase().uploadPacketCollection(packetCollection, null); + try { + database.getOpenDatabase().uploadPacketCollection(packetCollection, updateToken); + } catch (SolarThingDatabaseException solarThingDatabaseException) { + // expect that we are unauthorized to change the document + assertInstanceOf(CouchDbUnauthorizedException.class, solarThingDatabaseException.getCause()); + } + } +} diff --git a/client/src/test/java/me/retrodaredevil/solarthing/integration/DatabaseRejectOldPacketsTest.java b/client/src/test/java/me/retrodaredevil/solarthing/integration/DatabaseRejectOldPacketsTest.java new file mode 100644 index 00000000..9db2ceca --- /dev/null +++ b/client/src/test/java/me/retrodaredevil/solarthing/integration/DatabaseRejectOldPacketsTest.java @@ -0,0 +1,104 @@ +package me.retrodaredevil.solarthing.integration; + +import me.retrodaredevil.couchdbjava.CouchDbInstance; +import me.retrodaredevil.couchdbjava.exception.CouchDbCodeException; +import me.retrodaredevil.couchdbjava.exception.CouchDbException; +import me.retrodaredevil.couchdbjava.response.ErrorResponse; +import me.retrodaredevil.solarthing.SolarThingDatabaseType; +import me.retrodaredevil.solarthing.database.SolarThingDatabase; +import me.retrodaredevil.solarthing.database.couchdb.CouchDbSolarThingDatabase; +import me.retrodaredevil.solarthing.database.exception.SolarThingDatabaseException; +import me.retrodaredevil.solarthing.misc.device.CelsiusCpuTemperaturePacket; +import me.retrodaredevil.solarthing.packets.collection.PacketCollection; +import me.retrodaredevil.solarthing.packets.collection.PacketCollectionIdGenerator; +import me.retrodaredevil.solarthing.packets.collection.PacketCollections; +import me.retrodaredevil.solarthing.packets.instance.InstanceFragmentIndicatorPackets; +import me.retrodaredevil.solarthing.packets.instance.InstanceSourcePackets; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("integration") +public class DatabaseRejectOldPacketsTest { + + private static PacketCollection createSimplePacketCollection(Instant instant) { + // Note that in a production environment, we would make sure that the packets in a given packet collection + // actually belong in a particular database, but for testing we don't care. (A CpuTemperaturePacket shouldn't end up in the events database) + return PacketCollections.createFromPackets( + instant, + Arrays.asList( + new CelsiusCpuTemperaturePacket(null, 20.2f, null), + InstanceSourcePackets.create("default"), + InstanceFragmentIndicatorPackets.create(1) + ), + PacketCollectionIdGenerator.Defaults.UNIQUE_GENERATOR, + ZoneId.systemDefault() + ); + } + + + @ParameterizedTest + @MethodSource("me.retrodaredevil.solarthing.integration.DatabaseService#all") + void test(DatabaseService databaseService) throws CouchDbException, SolarThingDatabaseException { + IntegrationSetup.setup(IntegrationUtil.createCouchDbInstance(databaseService, IntegrationUtil.DEFAULT_ADMIN_AUTH)); + + // Uploader user + CouchDbInstance uploaderInstance = IntegrationUtil.createCouchDbInstance(databaseService, IntegrationUtil.getAuthFor(SolarThingDatabaseType.UserType.UPLOADER)); + SolarThingDatabase database = CouchDbSolarThingDatabase.create(uploaderInstance); + + Instant pastInstant = Instant.now().minus(Duration.ofMinutes(30)); + Instant futureInstant = Instant.now().plus(Duration.ofMinutes(4)); + Instant tinyBitInTheFutureInstant = Instant.now().plus(Duration.ofMinutes(1)); + PacketCollection pastPacketCollection = createSimplePacketCollection(pastInstant); + try { + database.getStatusDatabase().uploadPacketCollection(pastPacketCollection, null); + fail("We expect this to fail"); + } catch (SolarThingDatabaseException solarThingDatabaseException) { + expectForbiddenResponse(solarThingDatabaseException); + } + try { + database.getEventDatabase().uploadPacketCollection(pastPacketCollection, null); + fail("We expect this to fail"); + } catch (SolarThingDatabaseException solarThingDatabaseException) { + expectForbiddenResponse(solarThingDatabaseException); + } + + try { + database.getOpenDatabase().uploadPacketCollection(pastPacketCollection, null); + fail("We expect this to fail"); + } catch (SolarThingDatabaseException solarThingDatabaseException) { + expectForbiddenResponse(solarThingDatabaseException); + } + try { + // future solarthing_open test + database.getOpenDatabase().uploadPacketCollection(createSimplePacketCollection(futureInstant), null); + fail("We expect this to fail"); + } catch (SolarThingDatabaseException solarThingDatabaseException) { + expectForbiddenResponse(solarThingDatabaseException); + } + // We expect to be able to upload a tiny bit in the future to SolarThing open + database.getOpenDatabase().uploadPacketCollection(createSimplePacketCollection(tinyBitInTheFutureInstant), null); + } + private static void expectForbiddenResponse(SolarThingDatabaseException solarThingDatabaseException) { + // NOTE: The cause is an implementation detail of a SolarThingDatabaseException + // Remember that we don't want to rely on implementation details in actual code, + // but it's OK in a test like this. + assertInstanceOf(CouchDbCodeException.class, solarThingDatabaseException.getCause()); + + CouchDbCodeException e = (CouchDbCodeException) solarThingDatabaseException.getCause(); + + // We expect to have thrown a forbidden error as described here: https://docs.couchdb.org/en/stable/ddocs/ddocs.html#validate-document-update-functions + assertEquals(403, e.getCode()); + ErrorResponse errorResponse = e.getErrorResponse(); + assertNotNull(errorResponse); + assertEquals("forbidden", errorResponse.getError()); + assertTrue(errorResponse.getReason().contains("dateMillis")); + } +} diff --git a/core/src/main/java/me/retrodaredevil/couchdb/design/DesignResource.java b/core/src/main/java/me/retrodaredevil/couchdb/design/DesignResource.java index fb80a94c..69bc061d 100644 --- a/core/src/main/java/me/retrodaredevil/couchdb/design/DesignResource.java +++ b/core/src/main/java/me/retrodaredevil/couchdb/design/DesignResource.java @@ -6,10 +6,16 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +/** + * Design resources are files used to create design documents for CouchDB. + *
+ * Some of these design documents are validate functions: Validate Document Update Functions.
+ *
+ */
public enum DesignResource {
// Validate
VALIDATE_JAVASCRIPT_READONLY_AUTH("validation/readonly_auth.js"),
- VALIDATE_JAVASCRIPT_UPLOAD_ONLY("validation/upload_only.js"),
+ VALIDATE_JAVASCRIPT_SOLARTHING_OPEN_VALIDATE("validation/solarthing_open_validation.js"),
// View
VIEW_JAVASCRIPT_MILLIS_NULL("view/millisNull.js"),
diff --git a/core/src/main/java/me/retrodaredevil/couchdb/design/MutablePacketsDesign.java b/core/src/main/java/me/retrodaredevil/couchdb/design/MutablePacketsDesign.java
index 468e86fe..86ff29cc 100644
--- a/core/src/main/java/me/retrodaredevil/couchdb/design/MutablePacketsDesign.java
+++ b/core/src/main/java/me/retrodaredevil/couchdb/design/MutablePacketsDesign.java
@@ -20,6 +20,10 @@ public MutablePacketsDesign setReadonlyAuth() {
this.validateDocUpdate = DesignResource.VALIDATE_JAVASCRIPT_READONLY_AUTH.getAsString();
return this;
}
+ public MutablePacketsDesign setSolarThingOpenValidate() {
+ this.validateDocUpdate = DesignResource.VALIDATE_JAVASCRIPT_SOLARTHING_OPEN_VALIDATE.getAsString();
+ return this;
+ }
public MutablePacketsDesign addLast24HoursFilter() {
filters.put("last24Hours", DesignResource.FILTER_JAVASCRIPT_RECENT_PACKETS.getAsString());
return this;
diff --git a/core/src/main/java/me/retrodaredevil/solarthing/SolarThingDatabaseType.java b/core/src/main/java/me/retrodaredevil/solarthing/SolarThingDatabaseType.java
index 0c603bb3..e62ddfc3 100644
--- a/core/src/main/java/me/retrodaredevil/solarthing/SolarThingDatabaseType.java
+++ b/core/src/main/java/me/retrodaredevil/solarthing/SolarThingDatabaseType.java
@@ -90,7 +90,9 @@ public boolean isReadonlyByAll() {
return this == CLOSED;
}
public @NotNull Set