fix(cloudwatch): raise ResourceNotFoundException when a tag operation names a resource that does not exist (#4783)

* fix(cloudwatch): raise ResourceNotFoundException when a tag operation names a resource that does not exist

TagResource, UntagResource and ListTagsForResource answered 200 for any ARN, so
tagging a typo'd or deleted alarm, dashboard or metric stream told the caller the
tag had been applied. AWS raises ResourceNotFoundException. All three operations
now resolve the ARN first and report a 404 when nothing answers to it.

The fix sits in the three services rather than in the handlers, because CloudWatch
Metrics serves these operations over both Query and JSON 1.1 and the two handlers
route to the same service methods. A handler-level check would have left the other
protocol still answering 200.

What the 200 was hiding is worth naming, because "return an error instead of 200"
understates it. The write path resolved the ARN and then did nothing with the miss:
tagResource and untagResource each ended in an ifPresent, so a tag against an
unknown ARN was discarded, and ListTagsForResource answered the empty map. So the
ARN was neither rejected nor recorded. Nothing was stored against a phantom
resource, which is the one mercy here, but a caller that wrote a tag and read back
an empty list had no way to tell "this resource has no tags" from "this resource
does not exist".

That is why ListTagsForResource is included rather than left alone. It is the one
operation whose answer changes for a caller doing a speculative list, and the one
whose leniency made the silent write survive a round trip. No test or caller in the
repository depended on the empty-map answer for an unknown ARN: the only internal
caller is CloudWatchDashboardCfnProvisioner.reconcileTags, and both of its call
sites pass an ARN that putDashboard has just returned.

The error code is ResourceNotFoundException, not the shorter ResourceNotFound that
GetDashboard, DeleteDashboards and SetAlarmState answer with. Those are two separate
shapes in the CloudWatch model, mapped to two exception classes by the SDK, and the
model declares the longer one on exactly these three operations.

CloudWatch Logs needed no change. Its own TagResource, UntagResource and
ListTagsForResource already resolve the log group and raise
ResourceNotFoundException, which the new test pins so the two halves of CloudWatch
tagging cannot diverge in the other direction.

* fix(cloudwatch): resolve a dashboard by either the regionless or the region-ful form of its ARN

Dashboards are a global resource and AWS documents their ARN without a region,
arn:aws:cloudwatch::<account-id>:dashboard/<dashboard-name>, on both the TagResource
and the ListTagsForResource pages. Floci mints a region-ful one. The dashboard
lookup now accepts both shapes deliberately, so a caller holding either resolves.

Accepting both is what stops the preceding commit regressing a caller that does the
documented thing. Until the tag operations raised on a miss, the divergence was
invisible on this path: the documented ARN matched no stored dashboard, and the
answer was a silent 200 with an empty tag map. Turning a miss into
ResourceNotFoundException would have turned that into a hard 404 for a dashboard
that exists.

Only an absent region stands in for the dashboard's own, so an ARN naming some other
region is still a miss and does not resolve to a same-named dashboard here.

What Floci mints is not changed. Clients may already hold the ARNs it has handed
out, so bringing the minted shape into line with AWS alters values already in
circulation and belongs in its own change; it is tracked separately. The tolerant
lookup is all that is needed to keep this path correct, and it is strictly more
permissive than before, so it cannot break a caller that works today.

Alarms and metric streams were checked rather than assumed: both mint region-ful
ARNs and AWS documents both region-ful, so the question is dashboards only.

Also reworded the not-found message on the alarm path to name the ARN that did not
resolve instead of asserting it was an alarm. Both tag handlers route every ARN that
is not a dashboard and not a metric stream to that path, including kinds AWS
considers taggable and Floci does not serve, a Contributor Insights insight-rule ARN
being the one AWS documents. Reporting that no resource matches the ARN is true of
those; "alarm not found" was not. No Contributor Insights support is added here.

* test(cloudwatch): assert a refused tag call left the real resource untagged

The not-found tests tagged the unknown ARN env=prod and then re-tagged the real
resource env=dev before reading it back, so a failed call that had written through
to the real resource would have been overwritten and the assertion would still
pass. Read the real resource's tags first; they must be empty.

* test(cloudwatch): import matchers and use configureAwsContentTypes

Import Matchers.not and java.util.Map in the new tests instead of writing
them inline, register the x-amz-json content types through
RestAssuredJsonUtils.configureAwsContentTypes() in place of registerParser
and the hand-built encoder config, and drop DeleteDashboards from the
requireDashboard Javadoc: only GetDashboard declares ResourceNotFound.

(cherry picked from commit 0c8d1848541392ff91991121d96586db564cb3f8)
This commit is contained in:
Allen Sanborn
2026-10-01 12:21:47 -05:00
committed by Zakaria
parent cbb95ba26c
commit ccdf32335d
7 changed files with 585 additions and 47 deletions
@@ -179,29 +179,82 @@ public class CloudWatchDashboardsService {
} }
public Map<String, String> listTagsForResource(String resourceArn, String region) { public Map<String, String> listTagsForResource(String resourceArn, String region) {
return findByArn(resourceArn, region).map(Dashboard::getTags).orElse(Map.of()); return requireDashboard(resourceArn, region).getTags();
} }
public void tagResource(String resourceArn, Map<String, String> tags, String region) { public void tagResource(String resourceArn, Map<String, String> tags, String region) {
findByArn(resourceArn, region).ifPresent(dashboard -> { Dashboard dashboard = requireDashboard(resourceArn, region);
dashboard.getTags().putAll(tags); dashboard.getTags().putAll(tags);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
});
} }
public void untagResource(String resourceArn, List<String> tagKeys, String region) { public void untagResource(String resourceArn, List<String> tagKeys, String region) {
findByArn(resourceArn, region).ifPresent(dashboard -> { Dashboard dashboard = requireDashboard(resourceArn, region);
tagKeys.forEach(dashboard.getTags()::remove); tagKeys.forEach(dashboard.getTags()::remove);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
}); }
/**
* Resolves the dashboard an ARN names, or reports that nothing does. The tag operations
* declare {@code ResourceNotFoundException}, which is a different shape from the
* {@code ResourceNotFound} that {@code GetDashboard} declares,
* so this is not the {@link #notFound(String)} used by the rest of this service.
*/
private Dashboard requireDashboard(String resourceArn, String region) {
return findByArn(resourceArn, region).orElseThrow(() ->
new AwsException("ResourceNotFoundException",
"Dashboard does not exist: " + resourceArn, 404));
} }
private java.util.Optional<Dashboard> findByArn(String resourceArn, String region) { private java.util.Optional<Dashboard> findByArn(String resourceArn, String region) {
return dashboardStore.scan(k -> k.startsWith(region + "::")).stream() return dashboardStore.scan(k -> k.startsWith(region + "::")).stream()
.filter(d -> d.getDashboardArn() != null && d.getDashboardArn().equals(resourceArn)) .filter(d -> namesDashboard(resourceArn, d.getDashboardArn()))
.findFirst(); .findFirst();
} }
/**
* Whether {@code candidate} is an ARN for the dashboard whose own ARN is {@code stored}.
*
* <p>Both the region-ful and the regionless form resolve, deliberately, because the two
* disagree here and a caller may hold either. Dashboards are a global resource and AWS
* documents their ARN without a region on both the TagResource and the ListTagsForResource
* pages, {@code arn:aws:cloudwatch::<account-id>:dashboard/<dashboard-name>}, while Floci
* mints a region-ful one. That divergence used to be invisible on this path: an ARN
* matching nothing was a silent success answering an empty tag map. Now that it is an
* error, a caller sending the documented ARN for a dashboard that exists would be told the
* dashboard does not, so the lookup accepts both shapes.
*
* <p>What Floci mints is left alone here on purpose. Clients may already hold the ARNs it
* has handed out, so bringing the minted shape into line with AWS is a separate change
* from making the lookup tolerant, and only the second one is needed to keep this path
* correct.
*
* <p>Only an absent region is accepted in place of the dashboard's own, so an ARN naming
* some other region is still a miss rather than resolving to a same-named dashboard
* elsewhere.
*/
private static boolean namesDashboard(String candidate, String stored) {
if (candidate == null || stored == null) {
return false;
}
return candidate.equals(stored) || candidate.equals(withoutRegion(stored));
}
/**
* Blanks the region field of an ARN, turning
* {@code arn:aws:cloudwatch:us-east-1:123:dashboard/x} into
* {@code arn:aws:cloudwatch::123:dashboard/x}. An ARN with fewer than six fields is
* returned unchanged, since there is no region field to blank.
*/
private static String withoutRegion(String arn) {
String[] fields = arn.split(":", 6);
if (fields.length < 6) {
return arn;
}
fields[3] = "";
return String.join(":", fields);
}
private static String key(String region, String dashboardName) { private static String key(String region, String dashboardName) {
return region + "::" + dashboardName; return region + "::" + dashboardName;
} }
@@ -332,35 +332,43 @@ public class CloudWatchMetricsService {
LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue); LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue);
} }
public Map<String, String> listTagsForResource(String resourceArn, String region) { /**
* Resolves the alarm an ARN names, or reports that nothing does.
*
* <p>CloudWatch's three tag operations each declare {@code ResourceNotFoundException},
* which is a different shape from the {@code ResourceNotFound} that {@code SetAlarmState}
* and {@code GetDashboard} declare; the SDK maps the two codes to two exception classes,
* so the tag path uses the longer one rather than the code used elsewhere in this service.
*
* <p>The message names the ARN rather than asserting it was an alarm, because both tag
* handlers route every ARN that is not a dashboard and not a metric stream here. That
* includes kinds AWS considers taggable and Floci does not serve, a Contributor Insights
* {@code insight-rule/} ARN being the one AWS documents; reporting that no resource
* matches the ARN is true of those, where "alarm not found" would not be.
*/
private MetricAlarm requireAlarm(String resourceArn, String region) {
return alarmStore.scan(k -> k.startsWith(region + "::")) return alarmStore.scan(k -> k.startsWith(region + "::"))
.stream() .stream()
.filter(a -> resourceArn.equals(a.getAlarmArn())) .filter(a -> a.getAlarmArn() != null && a.getAlarmArn().equals(resourceArn))
.findFirst() .findFirst()
.map(MetricAlarm::getTags) .orElseThrow(() -> new AwsException("ResourceNotFoundException",
.orElse(Map.of()); "No CloudWatch resource matches the ARN " + resourceArn + ".", 404));
}
public Map<String, String> listTagsForResource(String resourceArn, String region) {
return requireAlarm(resourceArn, region).getTags();
} }
public void tagResource(String resourceArn, Map<String, String> tags, String region) { public void tagResource(String resourceArn, Map<String, String> tags, String region) {
alarmStore.scan(k -> k.startsWith(region + "::")) MetricAlarm alarm = requireAlarm(resourceArn, region);
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.findFirst()
.ifPresent(alarm -> {
alarm.getTags().putAll(tags); alarm.getTags().putAll(tags);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
});
} }
public void untagResource(String resourceArn, List<String> tagKeys, String region) { public void untagResource(String resourceArn, List<String> tagKeys, String region) {
alarmStore.scan(k -> k.startsWith(region + "::")) MetricAlarm alarm = requireAlarm(resourceArn, region);
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.findFirst()
.ifPresent(alarm -> {
tagKeys.forEach(alarm.getTags()::remove); tagKeys.forEach(alarm.getTags()::remove);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
});
} }
// ──────────────────────────── Helpers ──────────────────────────── // ──────────────────────────── Helpers ────────────────────────────
@@ -196,21 +196,30 @@ public class CloudWatchMetricStreamsService {
} }
public Map<String, String> listTagsForResource(String resourceArn, String region) { public Map<String, String> listTagsForResource(String resourceArn, String region) {
return findByArn(resourceArn, region).map(MetricStream::getTags).orElse(Map.of()); return requireStream(resourceArn, region).getTags();
} }
public void tagResource(String resourceArn, Map<String, String> tags, String region) { public void tagResource(String resourceArn, Map<String, String> tags, String region) {
findByArn(resourceArn, region).ifPresent(stream -> { MetricStream stream = requireStream(resourceArn, region);
stream.getTags().putAll(tags); stream.getTags().putAll(tags);
streamStore.put(key(region, stream.getName()), stream); streamStore.put(key(region, stream.getName()), stream);
});
} }
public void untagResource(String resourceArn, List<String> tagKeys, String region) { public void untagResource(String resourceArn, List<String> tagKeys, String region) {
findByArn(resourceArn, region).ifPresent(stream -> { MetricStream stream = requireStream(resourceArn, region);
tagKeys.forEach(stream.getTags()::remove); tagKeys.forEach(stream.getTags()::remove);
streamStore.put(key(region, stream.getName()), stream); streamStore.put(key(region, stream.getName()), stream);
}); }
/**
* Resolves the stream an ARN names, or reports that nothing does. The tag operations declare
* {@code ResourceNotFoundException}, the same code {@code GetMetricStream} already answers
* with, so an unknown ARN here reads the same way an unknown name does.
*/
private MetricStream requireStream(String resourceArn, String region) {
return findByArn(resourceArn, region).orElseThrow(() ->
new AwsException("ResourceNotFoundException",
"Metric stream " + resourceArn + " does not exist.", 404));
} }
private Optional<MetricStream> findByArn(String resourceArn, String region) { private Optional<MetricStream> findByArn(String resourceArn, String region) {
@@ -0,0 +1,349 @@
package io.github.hectorvent.floci.services.cloudwatch;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
/**
* Tagging a CloudWatch resource whose ARN names nothing has to fail, on every one of the three
* tag operations and for every taggable kind CloudWatch owns.
*
* <p>Each kind is checked twice: once on an ARN that resolves, so the operation is known to work
* at all, and once on the same ARN with the resource name swapped for one that was never created.
* The two ARNs are byte-identical in shape, so "the ARN was malformed" cannot explain the second
* answer. Without that pairing a test asserting only the failure would also pass against a build
* whose tag operations were broken outright.
*
* <p>The failure to look for is {@code ResourceNotFoundException}. CloudWatch's model declares
* that code, not the shorter {@code ResourceNotFound} that {@code GetDashboard} and
* {@code SetAlarmState} answer with, for {@code TagResource}, {@code UntagResource} and
* {@code ListTagsForResource}; the SDK maps the two to different exception classes, so a caller
* catching the documented one would not see the other.
*/
@QuarkusTest
class CloudWatchTagUnknownResourceIntegrationTest {
private static final String CW_SCOPE =
"AWS4-HMAC-SHA256 Credential=test/20260205/us-east-1/monitoring/aws4_request";
private static final String LOGS_SCOPE =
"AWS4-HMAC-SHA256 Credential=test/20260205/us-east-1/logs/aws4_request";
private static final String JSON_1_0 = "application/x-amz-json-1.0";
private static final String JSON_1_1 = "application/x-amz-json-1.1";
private static final String PREFIX = "arn:aws:cloudwatch:us-east-1:000000000000:";
private static final String FIREHOSE_ARN =
"arn:aws:firehose:us-east-1:000000000000:deliverystream/metrics";
private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/metric-stream-role";
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
private static RequestSpecification query(String action) {
return given()
.contentType("application/x-www-form-urlencoded")
.header("Authorization", CW_SCOPE)
.formParam("Action", action);
}
private static RequestSpecification json(String contentType, String scope, String target, String body) {
return given()
.contentType(contentType)
.header("Authorization", scope)
.header("X-Amz-Target", target)
.body(body);
}
private static RequestSpecification metricsJson(String target, String body) {
return json(JSON_1_0, CW_SCOPE, "GraniteServiceVersion20100801." + target, body);
}
private static RequestSpecification logsJson(String target, String body) {
return json(JSON_1_1, LOGS_SCOPE, "Logs_20140328." + target, body);
}
/** The three Query tag operations against one ARN, asserting the same outcome for each. */
private static void assertQueryTagOpsNotFound(String arn) {
query("TagResource")
.formParam("ResourceARN", arn)
.formParam("Tags.member.1.Key", "env")
.formParam("Tags.member.1.Value", "prod")
.when()
.post("/")
.then()
.statusCode(404)
.body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException"));
query("UntagResource")
.formParam("ResourceARN", arn)
.formParam("TagKeys.member.1", "env")
.when()
.post("/")
.then()
.statusCode(404)
.body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException"));
query("ListTagsForResource")
.formParam("ResourceARN", arn)
.when()
.post("/")
.then()
.statusCode(404)
.body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException"));
}
/** The same three operations against an ARN that does resolve, read back through a list. */
private static void assertQueryTagOpsSucceed(String arn) {
query("TagResource")
.formParam("ResourceARN", arn)
.formParam("Tags.member.1.Key", "env")
.formParam("Tags.member.1.Value", "prod")
.when()
.post("/")
.then()
.statusCode(200);
query("ListTagsForResource")
.formParam("ResourceARN", arn)
.when()
.post("/")
.then()
.statusCode(200)
.body("ListTagsForResourceResponse.ListTagsForResourceResult.Tags.member.Key",
equalTo("env"))
.body("ListTagsForResourceResponse.ListTagsForResourceResult.Tags.member.Value",
equalTo("prod"));
query("UntagResource")
.formParam("ResourceARN", arn)
.formParam("TagKeys.member.1", "env")
.when()
.post("/")
.then()
.statusCode(200);
query("ListTagsForResource")
.formParam("ResourceARN", arn)
.when()
.post("/")
.then()
.statusCode(200)
.body(not(containsString("<Key>env</Key>")));
}
@Test
void alarmTagOperationsRejectAnArnThatNamesNoAlarm() {
query("PutMetricAlarm")
.formParam("AlarmName", "tag-unknown-alarm")
.formParam("MetricName", "CPUUtilization")
.formParam("Namespace", "AWS/EC2")
.formParam("ComparisonOperator", "GreaterThanThreshold")
.formParam("EvaluationPeriods", "1")
.formParam("Period", "60")
.formParam("Statistic", "Average")
.formParam("Threshold", "80")
.when()
.post("/")
.then()
.statusCode(200);
assertQueryTagOpsSucceed(PREFIX + "alarm:tag-unknown-alarm");
assertQueryTagOpsNotFound(PREFIX + "alarm:tag-unknown-alarm-nosuch");
}
@Test
void dashboardTagOperationsRejectAnArnThatNamesNoDashboard() {
query("PutDashboard")
.formParam("DashboardName", "tag-unknown-dashboard")
.formParam("DashboardBody", "{\"widgets\":[]}")
.when()
.post("/")
.then()
.statusCode(200);
assertQueryTagOpsSucceed(PREFIX + "dashboard/tag-unknown-dashboard");
assertQueryTagOpsNotFound(PREFIX + "dashboard/tag-unknown-dashboard-nosuch");
}
@Test
void metricStreamTagOperationsRejectAnArnThatNamesNoStream() {
query("PutMetricStream")
.formParam("Name", "tag-unknown-stream")
.formParam("FirehoseArn", FIREHOSE_ARN)
.formParam("RoleArn", ROLE_ARN)
.formParam("OutputFormat", "json")
.formParam("IncludeFilters.member.1.Namespace", "AWS/EC2")
.when()
.post("/")
.then()
.statusCode(200);
assertQueryTagOpsSucceed(PREFIX + "metric-stream/tag-unknown-stream");
assertQueryTagOpsNotFound(PREFIX + "metric-stream/tag-unknown-stream-nosuch");
}
/**
* The dashboard ARN AWS documents carries no region, and the one Floci mints does. A
* caller sending the documented form for a dashboard that exists must not be told the
* dashboard does not exist, which is what raising on a miss would otherwise have made of
* a shape that used to fall through to an empty tag map.
*/
@Test
void theRegionlessDashboardArnAwsDocumentsAlsoResolves() {
query("PutDashboard")
.formParam("DashboardName", "tag-regionless-dashboard")
.formParam("DashboardBody", "{\"widgets\":[]}")
.when()
.post("/")
.then()
.statusCode(200);
assertQueryTagOpsSucceed("arn:aws:cloudwatch::000000000000:dashboard/tag-regionless-dashboard");
// Still only an absent region: another region's ARN remains a miss.
assertQueryTagOpsNotFound(
"arn:aws:cloudwatch:eu-west-1:000000000000:dashboard/tag-regionless-dashboard");
}
/**
* Both handlers route every ARN that is not a dashboard and not a metric stream to the
* alarm path, so a Contributor Insights ARN, which AWS documents as taggable and Floci
* does not serve, lands there. Reporting it as not found is right; calling it an alarm
* that was not found is not, so the message names the ARN instead.
*/
@Test
void anArnOfAKindFlociDoesNotServeIsReportedAgainstTheArn() {
query("TagResource")
.formParam("ResourceARN", PREFIX + "insight-rule/no-such-rule")
.formParam("Tags.member.1.Key", "env")
.formParam("Tags.member.1.Value", "prod")
.when()
.post("/")
.then()
.statusCode(404)
.body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException"))
.body("ErrorResponse.Error.Message",
containsString("insight-rule/no-such-rule"))
.body("ErrorResponse.Error.Message",
not(containsString("Alarm")));
}
/**
* CloudWatch Metrics serves the same three operations over JSON as well as Query, and
* AGENTS.md requires the two handlers not to drift. A fix applied to only one of them
* would leave the CLI and SDK v3 route still answering 200.
*/
@Test
void theJsonProtocolRejectsTheSameArns() {
metricsJson("PutMetricAlarm", """
{"AlarmName": "tag-unknown-json-alarm", "MetricName": "CPUUtilization",
"Namespace": "AWS/EC2", "ComparisonOperator": "GreaterThanThreshold",
"EvaluationPeriods": 1, "Period": 60, "Statistic": "Average", "Threshold": 80}""")
.when()
.post("/")
.then()
.statusCode(200);
String real = PREFIX + "alarm:tag-unknown-json-alarm";
String ghost = PREFIX + "alarm:tag-unknown-json-alarm-nosuch";
metricsJson("TagResource",
"{\"ResourceARN\": \"" + real + "\", \"Tags\": [{\"Key\": \"env\", \"Value\": \"prod\"}]}")
.when()
.post("/")
.then()
.statusCode(200);
metricsJson("ListTagsForResource", "{\"ResourceARN\": \"" + real + "\"}")
.when()
.post("/")
.then()
.statusCode(200)
.body("Tags[0].Key", equalTo("env"))
.body("Tags[0].Value", equalTo("prod"));
metricsJson("TagResource",
"{\"ResourceARN\": \"" + ghost + "\", \"Tags\": [{\"Key\": \"env\", \"Value\": \"prod\"}]}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", containsString("ResourceNotFoundException"));
metricsJson("UntagResource", "{\"ResourceARN\": \"" + ghost + "\", \"TagKeys\": [\"env\"]}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", containsString("ResourceNotFoundException"));
metricsJson("ListTagsForResource", "{\"ResourceARN\": \"" + ghost + "\"}")
.when()
.post("/")
.then()
.statusCode(404)
.body("__type", containsString("ResourceNotFoundException"));
}
/**
* CloudWatch Logs serves its own TagResource, UntagResource and ListTagsForResource over
* a separate handler, and already rejects an ARN that names no log group. Pinning it keeps
* the two halves of CloudWatch's tagging from diverging again in the other direction.
*/
@Test
void logGroupTagOperationsRejectAnArnThatNamesNoGroup() {
String real = "arn:aws:logs:us-east-1:000000000000:log-group:tag-unknown-group:*";
String ghost = "arn:aws:logs:us-east-1:000000000000:log-group:tag-unknown-group-nosuch:*";
logsJson("CreateLogGroup", "{\"logGroupName\": \"tag-unknown-group\"}")
.when()
.post("/")
.then()
.statusCode(200);
logsJson("TagResource",
"{\"resourceArn\": \"" + real + "\", \"tags\": {\"env\": \"prod\"}}")
.when()
.post("/")
.then()
.statusCode(200);
logsJson("ListTagsForResource", "{\"resourceArn\": \"" + real + "\"}")
.when()
.post("/")
.then()
.statusCode(200)
.body("tags.env", equalTo("prod"));
logsJson("TagResource",
"{\"resourceArn\": \"" + ghost + "\", \"tags\": {\"env\": \"prod\"}}")
.when()
.post("/")
.then()
.statusCode(400)
.body("__type", containsString("ResourceNotFoundException"));
logsJson("UntagResource", "{\"resourceArn\": \"" + ghost + "\", \"tagKeys\": [\"env\"]}")
.when()
.post("/")
.then()
.statusCode(400)
.body("__type", containsString("ResourceNotFoundException"));
logsJson("ListTagsForResource", "{\"resourceArn\": \"" + ghost + "\"}")
.when()
.post("/")
.then()
.statusCode(400)
.body("__type", containsString("ResourceNotFoundException"));
}
}
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -116,10 +117,10 @@ class CloudWatchDashboardsServiceTest {
@Test @Test
void tagsGivenOnCreateAreReadableByArn() { void tagsGivenOnCreateAreReadableByArn() {
service.putDashboard("ops", BODY, java.util.Map.of("team", "platform"), REGION); service.putDashboard("ops", BODY, Map.of("team", "platform"), REGION);
String arn = service.getDashboard("ops", REGION).getDashboardArn(); String arn = service.getDashboard("ops", REGION).getDashboardArn();
assertEquals(java.util.Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); assertEquals(Map.of("team", "platform"), service.listTagsForResource(arn, REGION));
assertTrue(CloudWatchDashboardsService.isDashboardArn(arn)); assertTrue(CloudWatchDashboardsService.isDashboardArn(arn));
} }
@@ -129,12 +130,12 @@ class CloudWatchDashboardsServiceTest {
*/ */
@Test @Test
void tagsOnAReplacingPutDoNotOverwriteTheExistingOnes() { void tagsOnAReplacingPutDoNotOverwriteTheExistingOnes() {
service.putDashboard("ops", BODY, java.util.Map.of("team", "platform"), REGION); service.putDashboard("ops", BODY, Map.of("team", "platform"), REGION);
String arn = service.getDashboard("ops", REGION).getDashboardArn(); String arn = service.getDashboard("ops", REGION).getDashboardArn();
service.putDashboard("ops", BODY, java.util.Map.of("team", "someone-else"), REGION); service.putDashboard("ops", BODY, Map.of("team", "someone-else"), REGION);
assertEquals(java.util.Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); assertEquals(Map.of("team", "platform"), service.listTagsForResource(arn, REGION));
} }
@Test @Test
@@ -142,11 +143,75 @@ class CloudWatchDashboardsServiceTest {
service.putDashboard("ops", BODY, REGION); service.putDashboard("ops", BODY, REGION);
String arn = service.getDashboard("ops", REGION).getDashboardArn(); String arn = service.getDashboard("ops", REGION).getDashboardArn();
service.tagResource(arn, java.util.Map.of("env", "dev"), REGION); service.tagResource(arn, Map.of("env", "dev"), REGION);
assertEquals(java.util.Map.of("env", "dev"), service.listTagsForResource(arn, REGION)); assertEquals(Map.of("env", "dev"), service.listTagsForResource(arn, REGION));
service.untagResource(arn, List.of("env"), REGION); service.untagResource(arn, List.of("env"), REGION);
assertEquals(java.util.Map.of(), service.listTagsForResource(arn, REGION)); assertEquals(Map.of(), service.listTagsForResource(arn, REGION));
}
/**
* AWS documents the dashboard ARN without a region and Floci mints one with a region, so
* both forms have to resolve. Before the tag operations raised on a miss this divergence
* was invisible here: the documented ARN matched nothing and got an empty tag map back.
*/
@Test
void bothTheRegionlessAndTheRegionfulDashboardArnResolve() {
service.putDashboard("ops", BODY, REGION);
String regionful = service.getDashboard("ops", REGION).getDashboardArn();
String regionless = "arn:aws:cloudwatch::000000000000:dashboard/ops";
assertEquals("arn:aws:cloudwatch:us-east-1:000000000000:dashboard/ops", regionful);
assertTrue(CloudWatchDashboardsService.isDashboardArn(regionless));
service.tagResource(regionless, Map.of("env", "dev"), REGION);
// Read back through the other form: one dashboard, reachable either way.
assertEquals(Map.of("env", "dev"), service.listTagsForResource(regionful, REGION));
assertEquals(Map.of("env", "dev"), service.listTagsForResource(regionless, REGION));
service.untagResource(regionless, List.of("env"), REGION);
assertEquals(Map.of(), service.listTagsForResource(regionful, REGION));
}
/**
* Only an absent region stands in for the dashboard's own. An ARN naming a different
* region must not resolve to the same-named dashboard in this one.
*/
@Test
void anArnNamingAnotherRegionDoesNotResolve() {
service.putDashboard("ops", BODY, REGION);
String elsewhere = "arn:aws:cloudwatch:eu-west-1:000000000000:dashboard/ops";
AwsException e = assertThrows(AwsException.class,
() -> service.listTagsForResource(elsewhere, REGION));
assertEquals("ResourceNotFoundException", e.getErrorCode());
}
/**
* An ARN naming no dashboard is an error on all three operations, not a silent no-op. The
* ARN used here differs from a working one only in the dashboard name, so the outcome
* cannot be explained by the ARN failing to parse as a dashboard ARN.
*/
@Test
void tagOperationsRejectAnArnThatNamesNoDashboard() {
service.putDashboard("ops", BODY, REGION);
String arn = service.getDashboard("ops", REGION).getDashboardArn();
String ghost = arn.replace("dashboard/ops", "dashboard/nosuch");
assertTrue(CloudWatchDashboardsService.isDashboardArn(ghost));
for (Runnable call : List.<Runnable>of(
() -> service.tagResource(ghost, Map.of("env", "prod"), REGION),
() -> service.untagResource(ghost, List.of("env"), REGION),
() -> service.listTagsForResource(ghost, REGION))) {
AwsException e = assertThrows(AwsException.class, call::run);
assertEquals("ResourceNotFoundException", e.getErrorCode());
assertEquals(404, e.getHttpStatus());
}
// The failures wrote nothing: the real dashboard still carries only what it was given.
assertEquals(Map.of(), service.listTagsForResource(arn, REGION));
service.tagResource(arn, Map.of("env", "dev"), REGION);
assertEquals(Map.of("env", "dev"), service.listTagsForResource(arn, REGION));
} }
@Test @Test
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService; import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
@@ -83,6 +84,34 @@ class CloudWatchMetricsTagsTest {
assertNull(tags.get("team")); assertNull(tags.get("team"));
} }
/**
* An ARN naming no alarm is an error on all three operations, not a silent no-op. The ARN
* used here differs from a working one only in the alarm name, so the outcome cannot be
* explained by the ARN failing to parse.
*/
@Test
void tagOperationsRejectAnArnThatNamesNoAlarm() {
MetricAlarm alarm = new MetricAlarm();
alarm.setAlarmName("test-alarm");
alarm.setAlarmArn("arn:aws:cloudwatch:us-east-1:000000000000:alarm:test-alarm");
service.putMetricAlarm(alarm, REGION);
String ghost = "arn:aws:cloudwatch:us-east-1:000000000000:alarm:nosuch";
for (Runnable call : List.<Runnable>of(
() -> service.tagResource(ghost, Map.of("env", "prod"), REGION),
() -> service.untagResource(ghost, List.of("env"), REGION),
() -> service.listTagsForResource(ghost, REGION))) {
AwsException e = assertThrows(AwsException.class, call::run);
assertEquals("ResourceNotFoundException", e.getErrorCode());
assertEquals(404, e.getHttpStatus());
}
// The failures wrote nothing: the real alarm still carries only what it was given.
assertEquals(Map.of(), service.listTagsForResource(alarm.getAlarmArn(), REGION));
service.tagResource(alarm.getAlarmArn(), Map.of("env", "dev"), REGION);
assertEquals(Map.of("env", "dev"), service.listTagsForResource(alarm.getAlarmArn(), REGION));
}
@Test @Test
void tagsViaPutMetricAlarmQuery() { void tagsViaPutMetricAlarmQuery() {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
@@ -232,4 +232,29 @@ class CloudWatchMetricStreamsServiceTest {
service.untagResource(arn, List.of("team"), REGION); service.untagResource(arn, List.of("team"), REGION);
assertFalse(service.listTagsForResource(arn, REGION).containsKey("team")); assertFalse(service.listTagsForResource(arn, REGION).containsKey("team"));
} }
/**
* An ARN naming no stream is an error on all three operations, not a silent no-op. The ARN
* used here differs from a working one only in the stream name, so the outcome cannot be
* explained by the ARN failing to parse as a metric stream ARN.
*/
@Test
void tagOperationsRejectAnArnThatNamesNoStream() {
String arn = service.putMetricStream(stream("ops"), REGION).getArn();
String ghost = arn.replace("metric-stream/ops", "metric-stream/nosuch");
assertTrue(CloudWatchMetricStreamsService.isMetricStreamArn(ghost));
for (Runnable call : List.<Runnable>of(
() -> service.tagResource(ghost, Map.of("env", "prod"), REGION),
() -> service.untagResource(ghost, List.of("env"), REGION),
() -> service.listTagsForResource(ghost, REGION))) {
AwsException e = assertThrows(AwsException.class, call::run);
assertEquals("ResourceNotFoundException", e.getErrorCode());
assertEquals(404, e.getHttpStatus());
}
// The failures wrote nothing: the real stream still carries only what it was given.
service.tagResource(arn, Map.of("team", "platform"), REGION);
assertEquals(Map.of("team", "platform"), service.listTagsForResource(arn, REGION));
}
} }