Skip to main content

fakecloud_dynamodb/
streams_dataplane.rs

1//! DynamoDB Streams data plane (`DynamoDBStreams_20120810`).
2//!
3//! Lambda event source mappings against `arn:aws:dynamodb:.../stream/...`
4//! depend on `ListStreams`, `DescribeStream`, `GetShardIterator`, and
5//! `GetRecords`. The control plane's `EnableStream` / `DescribeTable`
6//! already populate `DynamoTable::stream_records` on every mutation;
7//! this module is the consumer side that surfaces those records.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use http::StatusCode;
13use serde_json::{json, Value};
14
15use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
16
17use crate::state::{DynamoTable, SharedDynamoDbState};
18
19pub struct DynamoDbStreamsService {
20    state: SharedDynamoDbState,
21}
22
23impl DynamoDbStreamsService {
24    pub fn new(state: SharedDynamoDbState) -> Self {
25        Self { state }
26    }
27}
28
29#[async_trait]
30impl AwsService for DynamoDbStreamsService {
31    fn service_name(&self) -> &str {
32        "dynamodbstreams"
33    }
34
35    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
36        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
37        match req.action.as_str() {
38            "ListStreams" => self.list_streams(&req, &body),
39            "DescribeStream" => self.describe_stream(&req, &body),
40            "GetShardIterator" => self.get_shard_iterator(&req, &body),
41            "GetRecords" => self.get_records(&req, &body),
42            _ => Err(AwsServiceError::action_not_implemented(
43                "dynamodbstreams",
44                &req.action,
45            )),
46        }
47    }
48
49    fn supported_actions(&self) -> &[&str] {
50        &[
51            "ListStreams",
52            "DescribeStream",
53            "GetShardIterator",
54            "GetRecords",
55        ]
56    }
57}
58
59impl DynamoDbStreamsService {
60    fn list_streams(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
61        let table_filter = body["TableName"].as_str();
62        let accounts = self.state.read();
63        let state = match accounts.get(&req.account_id) {
64            Some(s) => s,
65            None => return Ok(AwsResponse::ok_json(json!({ "Streams": [] }))),
66        };
67        let mut streams = Vec::new();
68        for table in state.tables.values() {
69            if let Some(name) = table_filter {
70                if table.name != name {
71                    continue;
72                }
73            }
74            if !table.stream_enabled {
75                continue;
76            }
77            let Some(arn) = table.stream_arn.as_ref() else {
78                continue;
79            };
80            let label = stream_label(arn);
81            streams.push(json!({
82                "StreamArn": arn,
83                "TableName": table.name,
84                "StreamLabel": label,
85            }));
86        }
87        Ok(AwsResponse::ok_json(json!({ "Streams": streams })))
88    }
89
90    fn describe_stream(
91        &self,
92        req: &AwsRequest,
93        body: &Value,
94    ) -> Result<AwsResponse, AwsServiceError> {
95        let stream_arn = require_string(body, "StreamArn")?;
96        let accounts = self.state.read();
97        let state = accounts
98            .get(&req.account_id)
99            .ok_or_else(|| not_found("Stream", &stream_arn))?;
100        let table = state
101            .tables
102            .values()
103            .find(|t| t.stream_arn.as_deref() == Some(stream_arn.as_str()))
104            .ok_or_else(|| not_found("Stream", &stream_arn))?;
105
106        let view_type = table
107            .stream_view_type
108            .clone()
109            .unwrap_or_else(|| "NEW_AND_OLD_IMAGES".to_string());
110        let label = stream_label(&stream_arn);
111        let key_schema: Vec<Value> = table
112            .key_schema
113            .iter()
114            .map(|k| {
115                json!({
116                    "AttributeName": k.attribute_name,
117                    "KeyType": k.key_type,
118                })
119            })
120            .collect();
121
122        let body = json!({
123            "StreamDescription": {
124                "StreamArn": stream_arn,
125                "StreamLabel": label,
126                "StreamStatus": "ENABLED",
127                "StreamViewType": view_type,
128                "CreationRequestDateTime": table.created_at.timestamp() as f64,
129                "TableName": table.name,
130                "KeySchema": key_schema,
131                "Shards": [{
132                    "ShardId": "shardId-00000000000000000000-00000001",
133                    "SequenceNumberRange": {
134                        "StartingSequenceNumber": "0",
135                    },
136                }],
137            }
138        });
139        Ok(AwsResponse::ok_json(body))
140    }
141
142    fn get_shard_iterator(
143        &self,
144        req: &AwsRequest,
145        body: &Value,
146    ) -> Result<AwsResponse, AwsServiceError> {
147        let stream_arn = require_string(body, "StreamArn")?;
148        let shard_id = require_string(body, "ShardId")?;
149        let iterator_type = require_string(body, "ShardIteratorType")?;
150
151        let accounts = self.state.read();
152        let state = accounts
153            .get(&req.account_id)
154            .ok_or_else(|| not_found("Stream", &stream_arn))?;
155        let table = state
156            .tables
157            .values()
158            .find(|t| t.stream_arn.as_deref() == Some(stream_arn.as_str()))
159            .ok_or_else(|| not_found("Stream", &stream_arn))?;
160
161        // The iterator is anchored to an *exclusive start sequence number*,
162        // not a positional index into the records Vec. `add_stream_record`
163        // physically trims expired records off the front (`records.retain`),
164        // which would shift every index; a sequence-number anchor is stable
165        // across trims (like native Kinesis advancing by stored seq, not by
166        // Vec position). GetRecords returns records whose sequence_number is
167        // strictly greater than this anchor. bug-audit 2026-06-15, 4.4.
168        let records = table.stream_records.read();
169        let after_seq: String = match iterator_type.as_str() {
170            // Oldest retained record: anchor below every sequence number so the
171            // first GetRecords returns the whole retained window.
172            "TRIM_HORIZON" => "0".to_string(),
173            // After the newest record: anchor at the max current sequence so
174            // only records added later are returned.
175            "LATEST" => records
176                .iter()
177                .map(|r| r.dynamodb.sequence_number.clone())
178                .max_by(|a, b| cmp_seq(a, b))
179                .unwrap_or_else(|| "0".to_string()),
180            // Inclusive of the named record: anchor just below it. A sequence
181            // number that isn't present has aged out of the retained window (or
182            // never existed): AWS answers AT/AFTER_SEQUENCE_NUMBER for such a
183            // sequence with TrimmedDataAccessException, not ValidationException.
184            "AT_SEQUENCE_NUMBER" => {
185                let seq = require_string(body, "SequenceNumber")?;
186                if !records.iter().any(|r| r.dynamodb.sequence_number == seq) {
187                    return Err(trimmed_data_access(&seq));
188                }
189                exclusive_before(&seq)
190            }
191            // Exclusive of the named record: anchor exactly at it.
192            "AFTER_SEQUENCE_NUMBER" => {
193                let seq = require_string(body, "SequenceNumber")?;
194                if !records.iter().any(|r| r.dynamodb.sequence_number == seq) {
195                    return Err(trimmed_data_access(&seq));
196                }
197                seq
198            }
199            other => {
200                return Err(invalid_argument(&format!(
201                    "Unsupported ShardIteratorType: {other}",
202                )))
203            }
204        };
205
206        let token = format!("{stream_arn}|{shard_id}|{after_seq}");
207        Ok(AwsResponse::ok_json(json!({ "ShardIterator": token })))
208    }
209
210    fn get_records(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
211        let iterator = require_string(body, "ShardIterator")?;
212        let limit = body["Limit"].as_u64().unwrap_or(1000) as usize;
213
214        let parts: Vec<&str> = iterator.splitn(3, '|').collect();
215        if parts.len() != 3 {
216            return Err(invalid_argument("ShardIterator is invalid"));
217        }
218        let stream_arn = parts[0].to_string();
219        let shard_id = parts[1].to_string();
220        // Exclusive start sequence number (see get_shard_iterator). Index-based
221        // tokens minted by older builds would parse as a number too, but they
222        // are positions; since we now compare by sequence number this is
223        // self-correcting after one GetShardIterator. bug-audit 2026-06-15, 4.4.
224        let after_seq = parts[2].to_string();
225
226        let accounts = self.state.read();
227        let state = accounts
228            .get(&req.account_id)
229            .ok_or_else(|| not_found("Stream", &stream_arn))?;
230        let table = state
231            .tables
232            .values()
233            .find(|t| t.stream_arn.as_deref() == Some(stream_arn.as_str()))
234            .ok_or_else(|| not_found("Stream", &stream_arn))?;
235
236        // Records whose sequence number is strictly greater than the anchor,
237        // in stored (arrival) order. Front-trimming by `records.retain` cannot
238        // make us skip or replay: the anchor moves only by what we actually
239        // returned, never by physical position.
240        let records = table.stream_records.read();
241        let selected: Vec<&crate::state::StreamRecord> = records
242            .iter()
243            .filter(|r| {
244                cmp_seq(&r.dynamodb.sequence_number, &after_seq) == std::cmp::Ordering::Greater
245            })
246            .take(limit)
247            .collect();
248
249        let next_seq = selected
250            .last()
251            .map(|r| r.dynamodb.sequence_number.clone())
252            .unwrap_or(after_seq);
253        let records_json: Vec<Value> = selected
254            .iter()
255            .map(|r| stream_record_to_json(r, table))
256            .collect();
257
258        let next_token = format!("{stream_arn}|{shard_id}|{next_seq}");
259        Ok(AwsResponse::ok_json(json!({
260            "Records": records_json,
261            "NextShardIterator": next_token,
262        })))
263    }
264}
265
266fn stream_record_to_json(r: &crate::state::StreamRecord, table: &DynamoTable) -> Value {
267    let mut dynamodb = json!({
268        "ApproximateCreationDateTime": r.timestamp.timestamp() as f64,
269        "Keys": &r.dynamodb.keys,
270        "SequenceNumber": r.dynamodb.sequence_number,
271        "SizeBytes": r.dynamodb.size_bytes,
272        "StreamViewType": r.dynamodb.stream_view_type,
273    });
274    if let Some(ni) = r.dynamodb.new_image.as_ref() {
275        dynamodb["NewImage"] = json!(ni);
276    }
277    if let Some(oi) = r.dynamodb.old_image.as_ref() {
278        dynamodb["OldImage"] = json!(oi);
279    }
280    let mut out = json!({
281        "eventID": r.event_id,
282        "eventName": r.event_name,
283        "eventVersion": r.event_version,
284        "eventSource": r.event_source,
285        "awsRegion": r.aws_region,
286        "eventSourceARN": table.stream_arn.clone().unwrap_or_default(),
287        "dynamodb": dynamodb,
288    });
289    if let Some(ui) = r.user_identity.as_ref() {
290        // The DynamoDB Streams GetRecords wire shape capitalizes the Identity
291        // members (`PrincipalId`, `Type`) even though the surrounding record
292        // keys are camelCase; this matches what the AWS SDKs deserialize.
293        out["userIdentity"] = json!({
294            "PrincipalId": ui.principal_id,
295            "Type": ui.identity_type,
296        });
297    }
298    out
299}
300
301fn stream_label(stream_arn: &str) -> String {
302    stream_arn.rsplit('/').next().unwrap_or("").to_string()
303}
304
305/// Compare two DynamoDB stream sequence numbers numerically. Sequence numbers
306/// are minted by an atomic counter and zero-padded to a fixed width, so they
307/// are also lexicographically ordered; we still parse to `u128` so that an
308/// un-padded legacy value (or one of a different width) compares correctly.
309///
310/// Exported (via `lib.rs`) so the Streams->Lambda poller compares checkpoints
311/// with the exact same numeric ordering as GetRecords, instead of raw string
312/// order (which straddles the old->fixed-width sequence-format change).
313pub fn cmp_seq(a: &str, b: &str) -> std::cmp::Ordering {
314    match (a.parse::<u128>(), b.parse::<u128>()) {
315        (Ok(x), Ok(y)) => x.cmp(&y),
316        // Non-numeric values fall back to byte order (deterministic, total).
317        _ => a.cmp(b),
318    }
319}
320
321/// The largest sequence number strictly less than `seq`, used to build an
322/// exclusive anchor for `AT_SEQUENCE_NUMBER` (which is inclusive of the named
323/// record). For the numeric counter this is `seq - 1`; if `seq` is `0` or
324/// non-numeric we anchor at `"0"` so nothing earlier is skipped.
325fn exclusive_before(seq: &str) -> String {
326    match seq.parse::<u128>() {
327        Ok(n) if n > 0 => {
328            // Preserve the original zero-padded width so lexicographic order
329            // continues to match numeric order for downstream string compares.
330            format!("{:0width$}", n - 1, width = seq.len())
331        }
332        _ => "0".to_string(),
333    }
334}
335
336fn require_string(body: &Value, field: &str) -> Result<String, AwsServiceError> {
337    body[field]
338        .as_str()
339        .map(|s| s.to_string())
340        .ok_or_else(|| invalid_argument(&format!("{field} is required")))
341}
342
343fn invalid_argument(msg: &str) -> AwsServiceError {
344    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
345}
346
347/// A request to read from a sequence number that is no longer retained (or
348/// never existed) on the stream. DynamoDB Streams returns this on
349/// GetShardIterator for AT/AFTER_SEQUENCE_NUMBER against trimmed/absent data.
350fn trimmed_data_access(seq: &str) -> AwsServiceError {
351    AwsServiceError::aws_error(
352        StatusCode::BAD_REQUEST,
353        "TrimmedDataAccessException",
354        format!("Attempted access to a trimmed data: sequence number {seq} is no longer available in the stream"),
355    )
356}
357
358fn not_found(kind: &str, target: &str) -> AwsServiceError {
359    AwsServiceError::aws_error(
360        StatusCode::BAD_REQUEST,
361        "ResourceNotFoundException",
362        format!("{kind} not found: {target}"),
363    )
364}
365
366pub fn shared(state: SharedDynamoDbState) -> Arc<dyn AwsService> {
367    Arc::new(DynamoDbStreamsService::new(state))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::state::{DynamoDbStreamRecord, DynamoTable, ProvisionedThroughput, StreamRecord};
374    use bytes::Bytes;
375    use chrono::Utc;
376    use http::{HeaderMap, Method};
377    use parking_lot::RwLock;
378    use std::collections::{BTreeMap, HashMap};
379    use std::sync::Arc;
380
381    fn make_state() -> SharedDynamoDbState {
382        Arc::new(RwLock::new(
383            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
384        ))
385    }
386
387    fn req(action: &str, body: Value) -> AwsRequest {
388        AwsRequest {
389            service: "dynamodbstreams".into(),
390            action: action.into(),
391            region: "us-east-1".into(),
392            account_id: "123456789012".into(),
393            request_id: "r".into(),
394            headers: HeaderMap::new(),
395            query_params: HashMap::new(),
396            body: Bytes::from(serde_json::to_vec(&body).unwrap()),
397            body_stream: parking_lot::Mutex::new(None),
398            path_segments: vec![],
399            raw_path: "/".into(),
400            raw_query: String::new(),
401            method: Method::POST,
402            is_query_protocol: false,
403            access_key_id: None,
404            principal: None,
405        }
406    }
407
408    fn seed_table(state: &SharedDynamoDbState) -> String {
409        let mut accts = state.write();
410        let s = accts.get_or_create("123456789012");
411        let arn =
412            "arn:aws:dynamodb:us-east-1:123456789012:table/widgets/stream/2026-05-03T00:00:00.000"
413                .to_string();
414        let table = DynamoTable {
415            name: "widgets".to_string(),
416            arn: "arn:aws:dynamodb:us-east-1:123456789012:table/widgets".to_string(),
417            table_id: "id".to_string(),
418            key_schema: Vec::new(),
419            attribute_definitions: Vec::new(),
420            provisioned_throughput: ProvisionedThroughput {
421                read_capacity_units: 0,
422                write_capacity_units: 0,
423            },
424            items: Vec::new(),
425            gsi: Vec::new(),
426            lsi: Vec::new(),
427            tags: BTreeMap::new(),
428            created_at: Utc::now(),
429            status: "ACTIVE".to_string(),
430            item_count: 0,
431            size_bytes: 0,
432            billing_mode: "PAY_PER_REQUEST".to_string(),
433            ttl_attribute: None,
434            ttl_enabled: false,
435            resource_policy: None,
436            pitr_enabled: false,
437            kinesis_destinations: Vec::new(),
438            contributor_insights_status: "DISABLED".to_string(),
439            contributor_insights_counters: BTreeMap::new(),
440            stream_enabled: true,
441            stream_view_type: Some("NEW_AND_OLD_IMAGES".to_string()),
442            stream_arn: Some(arn.clone()),
443            stream_records: Arc::new(RwLock::new(Vec::new())),
444            sse_type: None,
445            sse_kms_key_arn: None,
446            deletion_protection_enabled: false,
447            on_demand_throughput: None,
448            table_class: "STANDARD".to_string(),
449        };
450        let rec = StreamRecord {
451            event_id: "e1".into(),
452            event_name: "INSERT".into(),
453            event_version: "1.1".into(),
454            event_source: "aws:dynamodb".into(),
455            aws_region: "us-east-1".into(),
456            event_source_arn: arn.clone(),
457            timestamp: Utc::now(),
458            dynamodb: DynamoDbStreamRecord {
459                keys: HashMap::new(),
460                new_image: Some(HashMap::new()),
461                old_image: None,
462                sequence_number: "1".into(),
463                size_bytes: 16,
464                stream_view_type: "NEW_AND_OLD_IMAGES".into(),
465            },
466            user_identity: None,
467        };
468        table.stream_records.write().push(rec);
469        s.tables.insert("widgets".to_string(), table);
470        arn
471    }
472
473    #[tokio::test]
474    async fn list_streams_returns_enabled_streams() {
475        let state = make_state();
476        let arn = seed_table(&state);
477        let svc = DynamoDbStreamsService::new(state);
478        let resp = svc.handle(req("ListStreams", json!({}))).await.unwrap();
479        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
480        let streams = body["Streams"].as_array().unwrap();
481        assert_eq!(streams.len(), 1);
482        assert_eq!(streams[0]["StreamArn"].as_str().unwrap(), arn);
483    }
484
485    #[tokio::test]
486    async fn describe_stream_returns_shard() {
487        let state = make_state();
488        let arn = seed_table(&state);
489        let svc = DynamoDbStreamsService::new(state);
490        let resp = svc
491            .handle(req("DescribeStream", json!({"StreamArn": arn})))
492            .await
493            .unwrap();
494        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
495        let desc = &body["StreamDescription"];
496        assert_eq!(desc["StreamStatus"].as_str().unwrap(), "ENABLED");
497        assert_eq!(desc["Shards"].as_array().unwrap().len(), 1);
498    }
499
500    #[tokio::test]
501    async fn get_records_round_trip_via_shard_iterator() {
502        let state = make_state();
503        let arn = seed_table(&state);
504        let svc = DynamoDbStreamsService::new(state);
505        let it_resp = svc
506            .handle(req(
507                "GetShardIterator",
508                json!({
509                    "StreamArn": arn,
510                    "ShardId": "shardId-00000000000000000000-00000001",
511                    "ShardIteratorType": "TRIM_HORIZON",
512                }),
513            ))
514            .await
515            .unwrap();
516        let it_body: Value = serde_json::from_slice(it_resp.body.expect_bytes()).unwrap();
517        let iterator = it_body["ShardIterator"].as_str().unwrap().to_string();
518        let resp = svc
519            .handle(req("GetRecords", json!({"ShardIterator": iterator})))
520            .await
521            .unwrap();
522        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
523        let recs = body["Records"].as_array().unwrap();
524        assert_eq!(recs.len(), 1);
525        assert_eq!(recs[0]["eventName"].as_str().unwrap(), "INSERT");
526    }
527
528    fn push_record(state: &SharedDynamoDbState, seq: &str, age_hours: i64, event_id: &str) {
529        let mut accts = state.write();
530        let s = accts.get_or_create("123456789012");
531        let table = s.tables.get_mut("widgets").unwrap();
532        let rec = StreamRecord {
533            event_id: event_id.into(),
534            event_name: "INSERT".into(),
535            event_version: "1.1".into(),
536            event_source: "aws:dynamodb".into(),
537            aws_region: "us-east-1".into(),
538            event_source_arn: table.stream_arn.clone().unwrap(),
539            timestamp: Utc::now() - chrono::Duration::hours(age_hours),
540            dynamodb: DynamoDbStreamRecord {
541                keys: HashMap::new(),
542                new_image: Some(HashMap::new()),
543                old_image: None,
544                sequence_number: seq.into(),
545                size_bytes: 16,
546                stream_view_type: "NEW_AND_OLD_IMAGES".into(),
547            },
548            user_identity: None,
549        };
550        table.stream_records.write().push(rec);
551    }
552
553    fn trim_front(state: &SharedDynamoDbState, n: usize) {
554        let accts = state.read();
555        let s = accts.get("123456789012").unwrap();
556        let table = s.tables.get("widgets").unwrap();
557        let mut recs = table.stream_records.write();
558        for _ in 0..n {
559            if !recs.is_empty() {
560                recs.remove(0);
561            }
562        }
563    }
564
565    // bug-audit 2026-06-15, 4.4: the iterator is anchored to a sequence number,
566    // not a Vec index. A consumer that has read up to record N must, after the
567    // front of the records Vec is physically trimmed (24h retention), continue
568    // exactly where it left off — no skipped or replayed records.
569    #[tokio::test]
570    async fn iterator_survives_front_trim_without_skip_or_replay() {
571        let state = make_state();
572        let arn = seed_table(&state); // seeds one record seq "1"
573                                      // Replace the seeded record set with a clean, ordered set seq 1..=5.
574        {
575            let accts = state.read();
576            let s = accts.get("123456789012").unwrap();
577            s.tables
578                .get("widgets")
579                .unwrap()
580                .stream_records
581                .write()
582                .clear();
583        }
584        for i in 1..=5u64 {
585            // First two records are aged so a later trim removes them.
586            let age = if i <= 2 { 30 } else { 0 };
587            push_record(&state, &format!("{i:021}"), age, &format!("e{i}"));
588        }
589        let svc = DynamoDbStreamsService::new(state.clone());
590
591        // Start at TRIM_HORIZON, read 3 records (seq 1,2,3).
592        let it_resp = svc
593            .handle(req(
594                "GetShardIterator",
595                json!({
596                    "StreamArn": arn,
597                    "ShardId": "shardId-00000000000000000000-00000001",
598                    "ShardIteratorType": "TRIM_HORIZON",
599                }),
600            ))
601            .await
602            .unwrap();
603        let it: Value = serde_json::from_slice(it_resp.body.expect_bytes()).unwrap();
604        let iterator = it["ShardIterator"].as_str().unwrap().to_string();
605
606        let r1 = svc
607            .handle(req(
608                "GetRecords",
609                json!({"ShardIterator": iterator, "Limit": 3}),
610            ))
611            .await
612            .unwrap();
613        let b1: Value = serde_json::from_slice(r1.body.expect_bytes()).unwrap();
614        let recs1 = b1["Records"].as_array().unwrap();
615        assert_eq!(recs1.len(), 3);
616        assert_eq!(recs1[0]["eventID"].as_str().unwrap(), "e1");
617        assert_eq!(recs1[2]["eventID"].as_str().unwrap(), "e3");
618        let next = b1["NextShardIterator"].as_str().unwrap().to_string();
619
620        // Now retention trims the two aged front records (seq 1,2) off the Vec.
621        // An index-based iterator would now mis-resolve and skip/replay; a
622        // sequence-anchored one continues correctly with seq 4,5.
623        trim_front(&state, 2);
624
625        let r2 = svc
626            .handle(req("GetRecords", json!({"ShardIterator": next})))
627            .await
628            .unwrap();
629        let b2: Value = serde_json::from_slice(r2.body.expect_bytes()).unwrap();
630        let recs2 = b2["Records"].as_array().unwrap();
631        assert_eq!(
632            recs2.len(),
633            2,
634            "must return exactly the un-consumed records after a front trim"
635        );
636        assert_eq!(recs2[0]["eventID"].as_str().unwrap(), "e4");
637        assert_eq!(recs2[1]["eventID"].as_str().unwrap(), "e5");
638    }
639
640    // bug-hunt 2026-07-01, finding 8: AT/AFTER_SEQUENCE_NUMBER against a
641    // sequence number that has aged out (or never existed) is a
642    // TrimmedDataAccessException, not a ValidationException.
643    #[tokio::test]
644    async fn get_shard_iterator_absent_sequence_is_trimmed_data_access() {
645        let state = make_state();
646        let arn = seed_table(&state); // seeds one record, seq "1"
647        let svc = DynamoDbStreamsService::new(state);
648        for iter_type in ["AT_SEQUENCE_NUMBER", "AFTER_SEQUENCE_NUMBER"] {
649            let err = svc
650                .handle(req(
651                    "GetShardIterator",
652                    json!({
653                        "StreamArn": arn,
654                        "ShardId": "shardId-00000000000000000000-00000001",
655                        "ShardIteratorType": iter_type,
656                        "SequenceNumber": "999999999",
657                    }),
658                ))
659                .await
660                .err()
661                .expect("absent sequence must be rejected");
662            assert!(
663                format!("{err:?}").contains("TrimmedDataAccessException"),
664                "{iter_type}: {err:?}"
665            );
666        }
667    }
668
669    #[tokio::test]
670    async fn describe_stream_unknown_arn_404s() {
671        let state = make_state();
672        let _ = seed_table(&state);
673        let svc = DynamoDbStreamsService::new(state);
674        let err = svc
675            .handle(req(
676                "DescribeStream",
677                json!({"StreamArn": "arn:aws:dynamodb:us-east-1:123456789012:table/missing/stream/x"}),
678            ))
679            .await
680            .err()
681            .expect("expected ResourceNotFound");
682        assert!(format!("{:?}", err).contains("ResourceNotFoundException"));
683    }
684}