Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 70 additions & 7 deletions docs/tests/products/product-discover.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Related documents:
- [G. Refusals by policy, 403 (TC-32 – TC-39)](#g-refusals-by-policy--403)
- [H. Malformed requests, 400 (TC-40 – TC-42)](#h-malformed-requests--400)
- [I. Policy switched off (TC-43 – TC-44)](#i-policy-switched-off)
- [J. Subscription status (TC-45 – TC-46)](#j-subscription-status)
- [9. Edge cases](#9-edge-cases)
- [10. Defect triage](#10-defect-triage)

Expand Down Expand Up @@ -198,7 +199,8 @@ text-searchable, so a single value defaults to `eq` and must match the whole nam
"name": "FloodRiskMapZones",
"description": "Flood risk zones for England and Wales as geographic areas, …",
"type": "topic",
"organisation": { "key": "ENV", "name": "Environment Agency (ENV)" }
"organisation": { "key": "ENV", "name": "Environment Agency (ENV)" },
"subscriptionStatus": "NOT_SUBSCRIBED"
}
],
"page": { "number": 0, "size": 20, "numberOfElements": 1, "totalElements": 1, "totalPages": 1 },
Expand All @@ -216,10 +218,8 @@ text-searchable, so a single value defaults to `eq` and must match the whole nam

### A result is a summary

A discovery result carries **only**: `id`, `name`, `description`, `type`, and an `organisation`
carrying `key` and `name`. Nothing else: no `topic`, no `source`, no `producer`, no `consumers`,
no `subscribedBy`, no attribute maps. Those belong to
[`GET /api/v1/product/{productId}`](product-view.md).
A discovery result carries **only**: `id`, `name`, `description`, `type`, an `organisation`
carrying `key` and `name`, and `subscriptionStatus`.

**Filtering and sorting are not limited to those members.** A caller may filter and sort on
anything in its allowed lists, including `topic` and `source`, which no result carries. This
Expand All @@ -228,6 +228,17 @@ separation is deliberate and has its own test case ([TC-09](#tc-09--filter-on-a-
Absent members are **omitted**, never `null` and never `""`. A product with no description simply
has no `description` key.

### `subscriptionStatus`

Whether the **calling** organisation holds a grant on the product, through any of its consumers:
`SUBSCRIBED` or `NOT_SUBSCRIBED`. The organisation is the token's `organisation` claim.

- It describes the caller only, so it is **not masked**: BCC, for which `subscribedBy` is masked,
still sees its own status.
- A grant counts whatever its `validity`, exactly as for `subscribedBy`.
- An organisation holding the product on several consumers is still one `SUBSCRIBED`.
- Absent when the token carries no organisation.

### The `page` object

| Field | Means |
Expand Down Expand Up @@ -478,14 +489,15 @@ absent because of masking.

**Given** ENV, which has no masked fields at all
**When** the request is posted
**Then** the single product carries exactly `id`, `name`, `description`, `type`, `organisation`
**Then** the single product carries exactly `id`, `name`, `description`, `type`, `organisation`,
`subscriptionStatus`
**And** `organisation` carries exactly `key` and `name`
**And** `topic`, `source`, `producer`, `consumers`, `subscribedBy`, `attributes` are **absent keys**
They are not present with a `null` value.

```bash
discover "$ENV_TOKEN" '{"text":"FloodRiskMapZones"}' | jq '.products[0] | keys, (.organisation | keys)'
# ["description","id","name","organisation","type"]
# ["description","id","name","organisation","subscriptionStatus","type"]
# ["key","name"]
```

Expand Down Expand Up @@ -1632,6 +1644,57 @@ answered.)

---

### J. Subscription status

#### TC-45: Each product reports whether the caller is subscribed

**Objective.** Confirm `subscriptionStatus` follows the caller's own grants.

**Preconditions.** P1–P4.

**Test data.** Empty body, each token. Grants as in [6.2](#62-grants-for-subscribedby).

**Given** the three callers
**When** each posts `{}`
**Then** exactly these products are `SUBSCRIBED`, and every other product is `NOT_SUBSCRIBED`:

| Caller | `SUBSCRIBED` |
|---|---|
| ENV | AffordableHousingCompletions, BristolAirQualitySensors, BrownfieldLandAvailability, HelpToBuyApplicants |
| HEG | BristolAirQualitySensors, RiverLevelTelemetry |
| BCC | AffordableHousingCompletions, FloodRiskMapZones, RiverLevelTelemetry |

```bash
for t in "$ENV_TOKEN" "$HEG_TOKEN" "$BCC_TOKEN"; do
discover "$t" '{}' | jq -c '[.products[] | select(.subscriptionStatus=="SUBSCRIBED") | .name] | sort'
done
```

*Why:* a grant on a product the caller cannot discover is not reported. BCC holds
PollutionIncidentReports but cannot discover it, so it does not appear.

#### TC-46: The status is the caller's own, not anyone else's

**Objective.** Confirm the same product reports differently per caller.

**Preconditions.** P1–P4.

**Given** RiverLevelTelemetry, granted to HEG and twice to BCC, and not to ENV
**When** each caller discovers `{"text": "RiverLevelTelemetry"}`
**Then** ENV sees `NOT_SUBSCRIBED`
**And** HEG and BCC see `SUBSCRIBED`, BCC once despite its two grants.

```bash
for t in "$ENV_TOKEN" "$HEG_TOKEN" "$BCC_TOKEN"; do
discover "$t" '{"text":"RiverLevelTelemetry"}' | jq -r '.products[0].subscriptionStatus'
done
# NOT_SUBSCRIBED
# SUBSCRIBED
# SUBSCRIBED
```

---

## 9. Edge cases

Each is cheap to run and each has caught, or is designed to catch, a real class of defect.
Expand Down
6 changes: 5 additions & 1 deletion docs/tests/products/product-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ the PDP at all.
],
"subscribedBy": [
{ "key": "HEG", "name": "Homes England", "consumers": 1, "since": "2026-02-11T07:00:00" }
]
],
"subscriptionStatus": "NOT_SUBSCRIBED"
}
```

Expand All @@ -178,6 +179,9 @@ Rules that hold for every `200`:
- A nested `consumers[].organisation` carries `key` and `name` only; it never carries `attributes`.
- Attribute maps keep their JSON types: a multi-valued attribute is an array, a number stays a
number.
- **`subscriptionStatus`** is the calling organisation's own status, `SUBSCRIBED` or
`NOT_SUBSCRIBED`, with the same value discovery reports. It is not masked, since it describes the
caller only. See [discover §4](product-discover.md#subscriptionstatus).
- The response carries **no `policy` block**. Discovery's response has one (it is how a UI builds a
filter panel); view returns the bare product.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ public ProductController(
description = "Searches data products by free text, filters on product fields and policy "
+ "attributes, sorting and paging. Each result is a summary - id, name, description, type "
+ "and the owning organisation's key and name - enough to recognise a product, see "
+ "whose it is, and ask for it by id; the rest of a "
+ "whose it is, and ask for it by id - plus subscriptionStatus, whether the calling "
+ "organisation holds a grant on it (SUBSCRIBED or NOT_SUBSCRIBED); the rest of a "
+ "product is returned by GET /api/v1/product/{productId}. Filtering and sorting are not "
+ "limited to those four: a search may filter and sort on anything policy allows it, "
+ "including fields the summary does not carry. The product.discover policy decides "
Expand All @@ -91,6 +92,7 @@ public ProductController(
@ApiResponse(responseCode = "500", description = "Internal server error")
public ProductDiscoveryResponseDTO discoverProducts(
@Valid @RequestBody(required = false) ProductDiscoveryRequestDTO criteria,
@Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal,
@Parameter(hidden = true) Optional<PolicyDecision<ProductDiscoveryPolicyDecisionDetails>> policyDecision) {
// The decision taken for this request is the only one there is: it carries the caller's
// search contract, which the service turns into the query. It is passed on rather than
Expand All @@ -102,7 +104,7 @@ public ProductDiscoveryResponseDTO discoverProducts(
decision.policy().resolution(),
decision.reasons(),
decision.details().evaluation()));
return productDiscoveryService.discover(criteria, policyDecision);
return productDiscoveryService.discover(criteria, organisationOf(principal), policyDecision);
}

@PostMapping("/subscribe")
Expand Down Expand Up @@ -140,8 +142,7 @@ public ProductSubscriptionResponseDTO subscribe(
Optional<PolicyDecision<ProductSubscriptionPolicyDecisionDetails>> policyDecision) {
// The decision is handed to the service rather than re-taken there: the terms applied must
// be the ones this request was allowed on.
return productSubscriptionService.subscribe(
subscription, principal == null ? null : principal.organisation(), policyDecision);
return productSubscriptionService.subscribe(subscription, organisationOf(principal), policyDecision);
}

@GetMapping("/{productId}")
Expand All @@ -168,6 +169,7 @@ public ProductSubscriptionResponseDTO subscribe(
@ApiResponse(responseCode = "500", description = "Internal server error")
public ResponseEntity<DiscoveredProductDTO> viewProduct(
@Parameter(description = "Product identifier") @PathVariable("productId") Long productId,
@Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal,
@Parameter(hidden = true) Optional<PolicyDecision<ProductViewPolicyDecisionDetails>> policyDecision) {
policyDecision.ifPresent(decision -> log.debug(
"Product view policy decision allow={} policy={} resolution={} reasons={} accessLevel={}",
Expand All @@ -177,8 +179,12 @@ public ResponseEntity<DiscoveredProductDTO> viewProduct(
decision.reasons(),
decision.details().accessLevel()));
return productViewService
.view(productId, policyDecision)
.view(productId, organisationOf(principal), policyDecision)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}

private static String organisationOf(EnhancedPrincipal principal) {
return principal == null ? null : principal.organisation();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
* @param producer the producer offering the product
* @param consumers the consumers holding a grant, with the terms of each grant
* @param subscribedBy the organisations using the product: those with at least one grant
* @param subscriptionStatus whether the calling organisation holds a grant on the product. Carried by
* both endpoints and not subject to masking: it describes the caller, not other organisations.
* Absent when the caller's organisation is not known
*/
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
Expand All @@ -55,7 +58,16 @@ public record DiscoveredProductDTO(
Organisation organisation,
Producer producer,
List<Consumer> consumers,
List<SubscribingOrganisation> subscribedBy) {
List<SubscribingOrganisation> subscribedBy,
SubscriptionStatus subscriptionStatus) {

/** The calling organisation's relation to a product. */
public enum SubscriptionStatus {
/** At least one of the organisation's consumers holds a grant on the product. */
SUBSCRIBED,
/** None of the organisation's consumers holds a grant on the product. */
NOT_SUBSCRIBED
}

@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public class ProductDiscoveryRepository {

private final String grantsSql;
private final String subscribingOrganisationsSql;
private final String subscribedProductIdsSql;

public ProductDiscoveryRepository(NamedParameterJdbcTemplate jdbc, DiscoverySchema schema) {
this.jdbc = jdbc;
Expand Down Expand Up @@ -95,6 +96,11 @@ public ProductDiscoveryRepository(NamedParameterJdbcTemplate jdbc, DiscoverySche
+ " WHERE pc.product_id IN (:ids)"
+ " GROUP BY pc.product_id, o.organisation_key, o.name"
+ " ORDER BY pc.product_id, o.organisation_key";
this.subscribedProductIdsSql = "SELECT DISTINCT pc.product_id"
+ " FROM " + this.schema + "product_consumer pc"
+ " JOIN " + this.schema + "consumer c ON c.id = pc.consumer_id"
+ " JOIN " + this.schema + "organisation o ON o.id = c.org_id"
+ " WHERE pc.product_id IN (:ids) AND o.organisation_key = :organisationKey";
}

/** One row per product of the page, keyed by the column aliases of {@link ProductSearchQuery}. */
Expand Down Expand Up @@ -194,6 +200,18 @@ public List<SubscribingOrganisation> findSubscribingOrganisations(Collection<Lon
toLocalDateTime(row.getTimestamp("since"))));
}

/**
* Which of the given products an organisation holds a grant on, through any of its consumers. As
* for {@link #findSubscribingOrganisations}, a grant row is access and its validity plays no part.
*/
public Set<Long> findSubscribedProductIds(String organisationKey, Collection<Long> productIds) {
if (organisationKey == null || productIds.isEmpty()) {
return Set.of();
}
return Set.copyOf(jdbc.queryForList(
subscribedProductIdsSql, Map.of("ids", productIds, "organisationKey", organisationKey), Long.class));
}

/** One grant ({@code product_consumer} row) with its consumer and the consumer's organisation. */
public record Grant(
long grantId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,21 @@ public interface ProductDiscoveryService {
* @throws uk.gov.dbt.ndtp.ia.node.management.exception.AccessRejectedException when the criteria
* ask for something policy does not permit, or the contract cannot be honoured ({@code 403})
*/
default ProductDiscoveryResponseDTO discover(
ProductDiscoveryRequestDTO criteria,
Optional<PolicyDecision<ProductDiscoveryPolicyDecisionDetails>> decision) {
return discover(criteria, null, decision);
}

/**
* As {@link #discover(ProductDiscoveryRequestDTO, Optional)}, reporting the calling
* organisation's subscription status on every product.
*
* @param callerOrganisation the calling organisation's key, from the token; null leaves the
* status out
*/
ProductDiscoveryResponseDTO discover(
ProductDiscoveryRequestDTO criteria,
String callerOrganisation,
Optional<PolicyDecision<ProductDiscoveryPolicyDecisionDetails>> decision);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ public interface ProductViewService {
* @throws uk.gov.dbt.ndtp.ia.node.management.exception.AccessRejectedException when the contract
* cannot be honoured ({@code 403})
*/
default Optional<DiscoveredProductDTO> view(
Long productId, Optional<PolicyDecision<ProductViewPolicyDecisionDetails>> decision) {
return view(productId, null, decision);
}

/**
* As {@link #view(Long, Optional)}, reporting the calling organisation's subscription status.
*
* @param callerOrganisation the calling organisation's key, from the token; null leaves the
* status out
*/
Optional<DiscoveredProductDTO> view(
Long productId, Optional<PolicyDecision<ProductViewPolicyDecisionDetails>> decision);
Long productId,
String callerOrganisation,
Optional<PolicyDecision<ProductViewPolicyDecisionDetails>> decision);
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public ProductDiscoveryServiceImpl(
@Override
public ProductDiscoveryResponseDTO discover(
ProductDiscoveryRequestDTO request,
String callerOrganisation,
Optional<PolicyDecision<ProductDiscoveryPolicyDecisionDetails>> decision) {

ProductSearchContract contract = planner.contractFor(WHAT, decision);
Expand All @@ -91,8 +92,9 @@ public ProductDiscoveryResponseDTO discover(
ProductSearchQuery query = planner.compile(WHAT, contract, criteria, PROJECTION, decision);

long total = repository.count(query);
List<DiscoveredProductDTO> products =
total == 0 ? List.of() : assembler.assemble(repository.findPage(query), contract, PROJECTION);
List<DiscoveredProductDTO> products = total == 0
? List.of()
: assembler.assemble(repository.findPage(query), contract, PROJECTION, callerOrganisation);

audit(contract, criteria, total, decision);
return response(contract, criteria, products, total);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ public ProductViewServiceImpl(

@Override
public Optional<DiscoveredProductDTO> view(
Long productId, Optional<PolicyDecision<ProductViewPolicyDecisionDetails>> decision) {
Long productId,
String callerOrganisation,
Optional<PolicyDecision<ProductViewPolicyDecisionDetails>> decision) {

ProductSearchContract contract = planner.contractFor(WHAT, decision);
ProductSearchQuery query = planner.compile(WHAT, contract, criteriaFor(productId), PROJECTION, decision);
Expand All @@ -76,7 +78,8 @@ public Optional<DiscoveredProductDTO> view(
// was found.
Optional<DiscoveredProductDTO> product = rows.isEmpty()
? Optional.empty()
: assembler.assemble(rows, contract, PROJECTION).stream().findFirst();
: assembler.assemble(rows, contract, PROJECTION, callerOrganisation).stream()
.findFirst();

audit(productId, contract, product, decision);
return product;
Expand Down
Loading
Loading