Skip to main content

teamcraft/
models.rs

1//! Database row types. They serialize directly into JSON API responses and
2//! template contexts.
3
4use chrono::{DateTime, Utc};
5use serde::Serialize;
6use uuid::Uuid;
7
8#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
9pub struct User {
10    pub id: Uuid,
11    pub username: String,
12    pub display_name: String,
13    pub email: Option<String>,
14    pub auth_source: String,
15    pub role: String,
16    pub active: bool,
17    pub created_at: DateTime<Utc>,
18    pub last_login_at: Option<DateTime<Utc>>,
19    pub skin: String,
20}
21
22pub const USER_COLUMNS: &str =
23    "id, username, display_name, email, auth_source, role, active, created_at, last_login_at, skin";
24
25#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
26pub struct ApiToken {
27    pub id: Uuid,
28    pub name: String,
29    pub token_prefix: String,
30    pub created_at: DateTime<Utc>,
31    pub last_used_at: Option<DateTime<Utc>>,
32    pub expires_at: Option<DateTime<Utc>>,
33    /// `full`, `write`, `read_only` or `mcp_only` (`domain::TokenScope`).
34    pub scope: String,
35    /// Sliding lifetime: expires after this many days without use.
36    pub idle_days: Option<i32>,
37}
38
39pub const TOKEN_COLUMNS: &str =
40    "id, name, token_prefix, created_at, last_used_at, expires_at, scope, idle_days";
41
42#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
43pub struct Project {
44    pub id: Uuid,
45    pub code: String,
46    pub name: String,
47    pub description: String,
48    pub active: bool,
49    pub created_at: DateTime<Utc>,
50    /// IdP group whose members are synchronised into this project group.
51    pub oidc_group: Option<String>,
52    pub member_count: i64,
53    /// Who created the group (`None` for groups created by administrators
54    /// before self-service groups existed).
55    pub created_by: Option<Uuid>,
56    /// Numbering scheme for generated item numbers (`None` = the built-in
57    /// `{TYPE}-{SEQ:6}`), see `domain::NumberScheme`.
58    pub number_scheme: Option<String>,
59    /// `internal` or `restricted` (see `domain::ProjectVisibility`).
60    pub visibility: String,
61}
62
63impl Project {
64    pub fn is_restricted(&self) -> bool {
65        self.visibility == crate::domain::ProjectVisibility::Restricted.as_str()
66    }
67}
68
69/// A member of a project group.
70#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
71pub struct ProjectMember {
72    pub user_id: Uuid,
73    pub username: String,
74    pub display_name: String,
75    pub role: String,
76    pub active: bool,
77    /// `manual` (added by a manager or an admin) or `oidc` (synchronised from the IdP).
78    pub source: String,
79    pub added_at: DateTime<Utc>,
80    /// Managers maintain the group: details and members.
81    pub manager: bool,
82    /// Revisions of the group the member has checked out.
83    pub checkouts: i64,
84}
85
86/// One row of the item search result, combined with its latest revision.
87#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
88pub struct ItemSummary {
89    pub id: Uuid,
90    pub number: String,
91    pub item_type: String,
92    pub unit: String,
93    pub project_code: Option<String>,
94    pub created_at: DateTime<Utc>,
95    pub latest_revision_id: Uuid,
96    pub latest_code: String,
97    pub latest_state: String,
98    pub name: String,
99    /// Newest released revision, if any (e.g. to fetch released files).
100    pub released_code: Option<String>,
101    /// When searching: a file whose name or content matched.
102    pub matched_file: Option<String>,
103    /// `make`, `purchased` or `standard`.
104    pub classification: String,
105    /// Custom attribute values of the latest revision.
106    pub attributes: serde_json::Value,
107}
108
109#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
110pub struct Item {
111    pub id: Uuid,
112    pub number: String,
113    pub item_type: String,
114    pub unit: String,
115    pub project_id: Option<Uuid>,
116    pub project_code: Option<String>,
117    pub created_by: Uuid,
118    pub created_by_name: String,
119    pub created_at: DateTime<Utc>,
120    /// Responsible user (default: the creator), see `services::ownership`.
121    pub owner_id: Uuid,
122    pub owner_name: String,
123    /// `make`, `purchased` or `standard` (`domain::Classification`).
124    pub classification: String,
125}
126
127/// Definition of a custom attribute (maintained by administrators).
128#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
129pub struct AttributeDef {
130    pub id: Uuid,
131    pub key: String,
132    pub label: String,
133    /// `text`, `number`, `boolean` or `choice` (`domain::AttributeType`).
134    pub data_type: String,
135    pub unit: String,
136    pub choices: Vec<String>,
137    /// Item types it applies to; empty = all.
138    pub item_types: Vec<String>,
139    pub position: i32,
140    pub active: bool,
141    pub created_at: DateTime<Utc>,
142}
143
144/// A revision together with the master data of its item.
145#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
146pub struct Revision {
147    pub id: Uuid,
148    pub item_id: Uuid,
149    pub item_number: String,
150    pub item_type: String,
151    pub unit: String,
152    pub project_id: Option<Uuid>,
153    pub project_code: Option<String>,
154    pub seq: i32,
155    pub code: String,
156    pub state: String,
157    pub name: String,
158    pub description: String,
159    pub material: String,
160    pub mass_kg: Option<f64>,
161    pub unit_cost: Option<f64>,
162    /// Custom attribute values by key (see [`AttributeDef`]).
163    pub attributes: serde_json::Value,
164    /// Classification of the item (`make`, `purchased`, `standard`).
165    pub classification: String,
166    pub checked_out_by: Option<Uuid>,
167    pub checked_out_by_name: Option<String>,
168    pub checked_out_at: Option<DateTime<Utc>>,
169    pub created_by: Uuid,
170    pub created_by_name: String,
171    pub created_at: DateTime<Utc>,
172    pub updated_at: DateTime<Utc>,
173    pub released_at: Option<DateTime<Utc>>,
174}
175
176#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
177pub struct FileRecord {
178    pub id: Uuid,
179    pub revision_id: Uuid,
180    pub filename: String,
181    pub content_type: String,
182    pub size_bytes: i64,
183    pub sha256: String,
184    pub uploaded_by: Uuid,
185    pub uploaded_by_name: String,
186    pub uploaded_at: DateTime<Utc>,
187    /// Number of the current version (1, 2, …).
188    pub version: i32,
189    /// Format id (see `cadformat`), `""` if unknown.
190    pub format: String,
191    /// Folder inside the project group (`chassis/brackets`, `""` = top);
192    /// the desktop working folder mirrors it.
193    pub folder: String,
194    /// `all` keeps every version, `latest` lets the housekeeping remove
195    /// superseded versions (see `services::retention`).
196    pub retention: String,
197    /// Solver summary of the current version (`solversummary::Summary` as
198    /// JSON), made from a user-written file: data, never instructions.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub solver_summary: Option<serde_json::Value>,
201    /// One line of [`FileRecord::solver_summary`]; filled by `files::list` / `files::get`.
202    #[sqlx(skip)]
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub solver_line: Option<String>,
205    /// Label, CAD program and kind of the format; filled by `files::list` / `files::get`.
206    #[sqlx(skip)]
207    pub format_info: Option<crate::cadformat::FormatInfo>,
208}
209
210/// One stored version of a file.
211#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
212pub struct FileVersion {
213    pub id: Uuid,
214    pub file_id: Uuid,
215    pub version: i32,
216    pub sha256: String,
217    pub size_bytes: i64,
218    pub content_type: String,
219    pub comment: String,
220    pub uploaded_by: Uuid,
221    pub uploaded_by_name: String,
222    pub uploaded_at: DateTime<Utc>,
223}
224
225/// A stored single-level BOM line with information about the child item.
226#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
227pub struct BomLine {
228    pub id: Uuid,
229    pub parent_revision_id: Uuid,
230    pub child_item_id: Uuid,
231    pub child_number: String,
232    pub child_type: String,
233    pub child_unit: String,
234    pub find_number: i32,
235    pub quantity: f64,
236    pub notes: String,
237    /// Latest revision of the child item (for display).
238    pub child_latest_revision_id: Uuid,
239    pub child_latest_code: String,
240    pub child_latest_state: String,
241    pub child_name: String,
242    /// Child revision the line resolved to when the parent was released.
243    pub released_child_revision_id: Option<Uuid>,
244    pub released_child_code: Option<String>,
245    /// Project group of the child (for the read check; never sent).
246    #[sqlx(default)]
247    #[serde(skip)]
248    pub child_project_id: Option<Uuid>,
249    /// Placeholder for a child the reader may not see: find number and
250    /// quantity stay; the child's identity and the notes (they describe the
251    /// hidden child, possibly written by a member) are removed.
252    #[sqlx(default)]
253    pub restricted: bool,
254}
255
256impl BomLine {
257    /// Turns the line into a placeholder for a hidden child.
258    pub fn mask(&mut self) {
259        let label = crate::services::access::RESTRICTED_LABEL;
260        self.child_item_id = Uuid::nil();
261        self.child_number = label.into();
262        self.child_type = String::new();
263        self.child_unit = String::new();
264        self.child_latest_revision_id = Uuid::nil();
265        self.child_latest_code = String::new();
266        self.child_latest_state = String::new();
267        self.child_name = String::new();
268        self.notes = String::new();
269        self.released_child_revision_id = None;
270        self.released_child_code = None;
271        self.child_project_id = None;
272        self.restricted = true;
273    }
274}
275
276/// A parent revision that uses a given item ("where used").
277#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
278pub struct WhereUsed {
279    pub line_id: Uuid,
280    pub parent_revision_id: Uuid,
281    pub parent_item_id: Uuid,
282    pub parent_number: String,
283    pub parent_code: String,
284    pub parent_name: String,
285    pub parent_state: String,
286    pub find_number: i32,
287    pub quantity: f64,
288    /// Revision of the item the parent was released with.
289    pub released_child_code: Option<String>,
290}
291
292/// One usage in a multi-level where-used report: `child_number` is used in
293/// the parent, which is `level` levels above the item asked about.
294#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
295pub struct WhereUsedLevel {
296    pub level: i32,
297    /// The item used in the parent (the item asked about on level 1).
298    pub child_number: String,
299    #[sqlx(flatten)]
300    #[serde(flatten)]
301    pub usage: WhereUsed,
302}
303
304/// A reference of a revision to another item besides the BOM (drawing of,
305/// specification for, references).
306#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
307pub struct ItemReference {
308    pub id: Uuid,
309    pub from_revision_id: Uuid,
310    pub to_item_id: Uuid,
311    pub to_number: String,
312    pub to_type: String,
313    pub kind: String,
314    pub note: String,
315    pub to_latest_revision_id: Uuid,
316    pub to_latest_code: String,
317    pub to_latest_state: String,
318    pub to_name: String,
319    /// Revision of the target when the referencing revision was released.
320    pub released_to_revision_id: Option<Uuid>,
321    pub released_to_code: Option<String>,
322    pub created_at: DateTime<Utc>,
323    /// Project group of the target (for the read check; never sent).
324    #[sqlx(default)]
325    #[serde(skip)]
326    pub to_project_id: Option<Uuid>,
327    /// Placeholder for a target the reader may not see.
328    #[sqlx(default)]
329    pub restricted: bool,
330}
331
332impl ItemReference {
333    /// Turns the reference into a placeholder for a hidden target (the kind
334    /// stays; the note describes the hidden target and is removed).
335    pub fn mask(&mut self) {
336        self.to_item_id = Uuid::nil();
337        self.note = String::new();
338        self.to_number = crate::services::access::RESTRICTED_LABEL.into();
339        self.to_type = String::new();
340        self.to_latest_revision_id = Uuid::nil();
341        self.to_latest_code = String::new();
342        self.to_latest_state = String::new();
343        self.to_name = String::new();
344        self.released_to_revision_id = None;
345        self.released_to_code = None;
346        self.to_project_id = None;
347        self.restricted = true;
348    }
349}
350
351/// A revision that references a given item (the other side of an
352/// [`ItemReference`]).
353#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
354pub struct Referrer {
355    pub reference_id: Uuid,
356    pub kind: String,
357    pub note: String,
358    pub from_revision_id: Uuid,
359    pub from_item_id: Uuid,
360    pub from_number: String,
361    pub from_code: String,
362    pub from_name: String,
363    pub from_state: String,
364    pub released_to_code: Option<String>,
365}
366
367/// A dependent item that has to be checked because an item it uses or
368/// references was released in a new revision or made obsolete.
369#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
370pub struct Impact {
371    pub id: Uuid,
372    /// The dependent.
373    pub item_id: Uuid,
374    pub item_number: String,
375    pub project_id: Option<Uuid>,
376    pub project_code: Option<String>,
377    pub revision_id: Uuid,
378    pub revision_code: String,
379    pub revision_state: String,
380    pub item_name: String,
381    /// What changed.
382    pub source_item_id: Uuid,
383    pub source_number: String,
384    pub source_revision_id: Uuid,
385    pub source_code: String,
386    pub source_name: String,
387    /// `released` or `obsolete`.
388    pub event: String,
389    /// `bom`, `drawing`, `specification`, `reference` or `file`.
390    pub relation: String,
391    pub detail: String,
392    pub change_id: Option<Uuid>,
393    pub change_number: Option<String>,
394    pub created_at: DateTime<Utc>,
395    pub resolved_at: Option<DateTime<Utc>>,
396    /// `acknowledged`, `superseded` or `obsolete`.
397    pub resolution: Option<String>,
398    pub resolved_by_name: Option<String>,
399    pub note: String,
400    /// Project group of the source item (for the read check; never sent).
401    #[sqlx(default)]
402    #[serde(skip)]
403    pub source_project_id: Option<Uuid>,
404    /// The source is an item the reader may not see: its identity and the
405    /// releasing change are removed ("a restricted item this depends on
406    /// was released").
407    #[sqlx(default)]
408    pub source_restricted: bool,
409}
410
411impl Impact {
412    /// Removes the releasing change order (the reader may not see it).
413    pub fn hide_change(&mut self) {
414        self.change_id = None;
415        self.change_number = None;
416    }
417
418    /// Removes the source when the reader may not see it. The releasing
419    /// change needs a database check of its own (`impacts::redact_all`).
420    pub fn redact(mut self, actor: &crate::auth::Actor) -> Self {
421        if !actor.can_see_project(self.source_project_id) {
422            self.source_item_id = Uuid::nil();
423            self.source_number = crate::services::access::RESTRICTED_LABEL.into();
424            self.source_revision_id = Uuid::nil();
425            self.source_code = String::new();
426            self.source_name = String::new();
427            self.detail = String::new();
428            self.change_id = None;
429            self.change_number = None;
430            self.source_project_id = None;
431            self.source_restricted = true;
432        }
433        self
434    }
435}
436
437#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
438pub struct Change {
439    pub id: Uuid,
440    pub number: String,
441    pub title: String,
442    pub description: String,
443    pub priority: String,
444    pub status: String,
445    pub created_by: Uuid,
446    pub created_by_name: String,
447    pub created_at: DateTime<Utc>,
448    pub updated_at: DateTime<Utc>,
449    pub submitted_at: Option<DateTime<Utc>>,
450    pub closed_at: Option<DateTime<Utc>>,
451}
452
453/// A line of a change order: a revision released through it, or a released revision that is
454/// obsoleted or superseded by it.
455#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
456pub struct ChangeItem {
457    pub revision_id: Uuid,
458    pub item_id: Uuid,
459    pub item_number: String,
460    pub code: String,
461    pub name: String,
462    pub state: String,
463    pub checked_out_by_name: Option<String>,
464    /// `release` (an in-work revision to release), `obsolete` or `supersede` (an action on a
465    /// released revision, applied when the change is released).
466    pub action: String,
467    /// For `supersede`: the number of the successor item.
468    pub successor_number: Option<String>,
469    /// Group of the successor (for the read check; never sent).
470    #[sqlx(default)]
471    #[serde(skip)]
472    pub successor_project_id: Option<Uuid>,
473    /// Project group of the item (for the read check; never sent).
474    #[sqlx(default)]
475    #[serde(skip)]
476    pub project_id: Option<Uuid>,
477    /// Placeholder for an item the reader may not see: identity removed.
478    #[sqlx(default)]
479    pub restricted: bool,
480}
481
482impl ChangeItem {
483    /// Turns the row into a placeholder for a hidden item.
484    pub fn mask(&mut self) {
485        self.revision_id = Uuid::nil();
486        self.item_id = Uuid::nil();
487        self.item_number = crate::services::access::RESTRICTED_LABEL.into();
488        self.code = String::new();
489        self.name = String::new();
490        self.state = String::new();
491        self.checked_out_by_name = None;
492        self.action = String::new();
493        self.successor_number = None;
494        self.successor_project_id = None;
495        self.project_id = None;
496        self.restricted = true;
497    }
498}
499
500#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
501pub struct ChangeReview {
502    pub reviewer_id: Uuid,
503    pub reviewer_username: String,
504    pub reviewer_name: String,
505    pub decision: String,
506    pub comment: String,
507    pub decided_at: Option<DateTime<Utc>>,
508    /// How the decision was signed off (`session`, `password`, `sso`, `token`).
509    #[sqlx(default)]
510    pub sign_off_method: Option<String>,
511    /// What the sign-off means, as the reviewer stated it.
512    #[sqlx(default)]
513    pub sign_off_meaning: String,
514    /// Why a pending reviewer can no longer decide (`deactivated`, `not_editor`,
515    /// `cannot_see_items`); `None` when they can, and for decided reviews.
516    #[sqlx(default)]
517    pub absent: Option<String>,
518}
519
520/// A review of an earlier round of a change (kept when a new round starts).
521#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
522pub struct ChangeReviewRound {
523    pub round: i32,
524    pub reviewer_id: Uuid,
525    pub reviewer_username: String,
526    pub reviewer_name: String,
527    pub decision: String,
528    pub comment: String,
529    pub decided_at: Option<DateTime<Utc>>,
530    pub closed_at: DateTime<Utc>,
531    #[sqlx(default)]
532    pub sign_off_method: Option<String>,
533    #[sqlx(default)]
534    pub sign_off_meaning: String,
535}
536
537/// An engineering change request (ECR).
538#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
539pub struct ChangeRequest {
540    pub id: Uuid,
541    pub number: String,
542    pub title: String,
543    pub description: String,
544    pub reason: String,
545    pub priority: String,
546    pub status: String,
547    pub requested_by: Uuid,
548    pub requested_by_name: String,
549    pub assignee: Option<Uuid>,
550    pub assignee_name: Option<String>,
551    pub assignee_username: Option<String>,
552    pub decision_comment: String,
553    pub decided_by: Option<Uuid>,
554    pub decided_by_name: Option<String>,
555    pub decided_at: Option<DateTime<Utc>>,
556    pub due_date: Option<chrono::NaiveDate>,
557    pub created_at: DateTime<Utc>,
558    pub updated_at: DateTime<Utc>,
559    pub closed_at: Option<DateTime<Utc>>,
560    /// Number of affected items (list views).
561    pub item_count: i64,
562}
563
564/// An item affected by a change request.
565#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
566pub struct RequestItem {
567    pub item_id: Uuid,
568    pub item_number: String,
569    pub item_type: String,
570    pub project_id: Option<Uuid>,
571    pub project_code: Option<String>,
572    /// The revision the problem was found in (if given).
573    pub revision_id: Option<Uuid>,
574    pub revision_code: Option<String>,
575    pub revision_state: Option<String>,
576    /// Name, code, state and id of the latest revision.
577    pub name: String,
578    pub latest_revision_id: Uuid,
579    pub latest_code: String,
580    pub latest_state: String,
581    pub note: String,
582    /// Placeholder for an item the reader may not see: identity removed.
583    #[sqlx(default)]
584    pub restricted: bool,
585}
586
587impl RequestItem {
588    /// Turns the row into a placeholder for a hidden item.
589    pub fn mask(&mut self) {
590        self.item_id = Uuid::nil();
591        self.item_number = crate::services::access::RESTRICTED_LABEL.into();
592        self.item_type = String::new();
593        self.project_id = None;
594        self.project_code = None;
595        self.revision_id = None;
596        self.revision_code = None;
597        self.revision_state = None;
598        self.name = String::new();
599        self.latest_revision_id = Uuid::nil();
600        self.latest_code = String::new();
601        self.latest_state = String::new();
602        // the note describes the hidden item (written by a member of its group)
603        self.note = String::new();
604        self.restricted = true;
605    }
606}
607
608#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
609pub struct RequestComment {
610    pub id: Uuid,
611    pub author_id: Uuid,
612    pub author_name: String,
613    pub body: String,
614    pub created_at: DateTime<Utc>,
615}
616
617#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
618pub struct RequestAttachment {
619    pub id: Uuid,
620    pub request_id: Uuid,
621    pub filename: String,
622    pub content_type: String,
623    pub size_bytes: i64,
624    pub sha256: String,
625    pub uploaded_by: Uuid,
626    pub uploaded_by_name: String,
627    pub uploaded_at: DateTime<Utc>,
628}
629
630#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
631pub struct AuditEntry {
632    pub id: i64,
633    pub at: DateTime<Utc>,
634    pub user_id: Option<Uuid>,
635    pub username: String,
636    pub action: String,
637    pub entity_type: String,
638    pub entity_id: Option<Uuid>,
639    pub summary: String,
640    pub details: serde_json::Value,
641    /// The entry names an item the reader may not see (then redacted).
642    #[sqlx(default)]
643    #[serde(skip)]
644    pub related_hidden: bool,
645    /// The text names an item or change order the reader may not see.
646    #[sqlx(default)]
647    #[serde(skip)]
648    pub mentions_hidden: bool,
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::auth::Actor;
655    use crate::domain::{ProjectVisibility, Role};
656    use crate::services::access::RESTRICTED_LABEL;
657
658    /// Marker that every identifying field of a fixture contains; after
659    /// masking it must not appear anywhere in the serialized row.
660    const SECRET: &str = "TOPSECRET";
661
662    fn id() -> Uuid {
663        Uuid::from_u128(0x1234_5678)
664    }
665
666    fn actor(hidden: Vec<Uuid>) -> Actor {
667        Actor {
668            id: Uuid::from_u128(1),
669            username: "reader".into(),
670            display_name: "Reader".into(),
671            role: Role::Viewer,
672            skin: String::new(),
673            preferred_cad: String::new(),
674            projects: Vec::new(),
675            hidden_projects: hidden,
676        }
677    }
678
679    fn secret(name: &str) -> String {
680        format!("{SECRET}-{name}")
681    }
682
683    fn bom_line() -> BomLine {
684        BomLine {
685            id: Uuid::from_u128(77),
686            parent_revision_id: Uuid::from_u128(78),
687            child_item_id: id(),
688            child_number: secret("number"),
689            child_type: secret("type"),
690            child_unit: secret("unit"),
691            find_number: 30,
692            quantity: 4.5,
693            notes: secret("notes"),
694            child_latest_revision_id: id(),
695            child_latest_code: secret("code"),
696            child_latest_state: secret("state"),
697            child_name: secret("name"),
698            released_child_revision_id: Some(id()),
699            released_child_code: Some(secret("released")),
700            child_project_id: Some(id()),
701            restricted: false,
702        }
703    }
704
705    fn reference() -> ItemReference {
706        ItemReference {
707            id: Uuid::from_u128(79),
708            from_revision_id: Uuid::from_u128(80),
709            to_item_id: id(),
710            to_number: secret("number"),
711            to_type: secret("type"),
712            kind: "drawing".into(),
713            note: secret("note"),
714            to_latest_revision_id: id(),
715            to_latest_code: secret("code"),
716            to_latest_state: secret("state"),
717            to_name: secret("name"),
718            released_to_revision_id: Some(id()),
719            released_to_code: Some(secret("released")),
720            created_at: Utc::now(),
721            to_project_id: Some(id()),
722            restricted: false,
723        }
724    }
725
726    fn impact(project: Option<Uuid>) -> Impact {
727        Impact {
728            id: Uuid::from_u128(81),
729            item_id: Uuid::from_u128(82),
730            item_number: "DEP-000001".into(),
731            project_id: None,
732            project_code: None,
733            revision_id: Uuid::from_u128(83),
734            revision_code: "A".into(),
735            revision_state: "draft".into(),
736            item_name: "Dependent".into(),
737            source_item_id: id(),
738            source_number: secret("number"),
739            source_revision_id: id(),
740            source_code: secret("code"),
741            source_name: secret("name"),
742            event: "released".into(),
743            relation: "bom".into(),
744            detail: secret("detail"),
745            change_id: Some(id()),
746            change_number: Some(secret("change")),
747            created_at: Utc::now(),
748            resolved_at: None,
749            resolution: None,
750            resolved_by_name: None,
751            note: String::new(),
752            source_project_id: project,
753            source_restricted: false,
754        }
755    }
756
757    fn change_item() -> ChangeItem {
758        ChangeItem {
759            revision_id: id(),
760            item_id: id(),
761            item_number: secret("number"),
762            code: secret("code"),
763            name: secret("name"),
764            state: secret("state"),
765            checked_out_by_name: Some(secret("user")),
766            action: secret("supersede"),
767            successor_number: Some(secret("successor")),
768            successor_project_id: Some(id()),
769            project_id: Some(id()),
770            restricted: false,
771        }
772    }
773
774    fn request_item() -> RequestItem {
775        RequestItem {
776            item_id: id(),
777            item_number: secret("number"),
778            item_type: secret("type"),
779            project_id: Some(id()),
780            project_code: Some(secret("project")),
781            revision_id: Some(id()),
782            revision_code: Some(secret("code")),
783            revision_state: Some(secret("state")),
784            name: secret("name"),
785            latest_revision_id: id(),
786            latest_code: secret("latest"),
787            latest_state: secret("state"),
788            note: secret("note"),
789            restricted: false,
790        }
791    }
792
793    fn json<T: Serialize>(value: &T) -> String {
794        serde_json::to_string(value).unwrap()
795    }
796
797    #[test]
798    fn masked_bom_line_leaks_nothing_but_position_and_quantity() {
799        let mut line = bom_line();
800        assert!(
801            json(&line).contains(SECRET),
802            "the fixture must carry the marker"
803        );
804        line.mask();
805        let out = json(&line);
806        assert!(!out.contains(SECRET), "leaked: {out}");
807        assert!(!out.contains(&id().to_string()), "leaked an id: {out}");
808        assert!(line.restricted);
809        assert_eq!(line.child_number, RESTRICTED_LABEL);
810        assert_eq!(line.child_item_id, Uuid::nil());
811        assert_eq!(line.find_number, 30);
812        assert_eq!(line.quantity, 4.5);
813        assert!(line.child_project_id.is_none());
814    }
815
816    #[test]
817    fn masked_reference_keeps_only_its_kind() {
818        let mut r = reference();
819        r.mask();
820        let out = json(&r);
821        assert!(!out.contains(SECRET), "leaked: {out}");
822        assert!(!out.contains(&id().to_string()), "leaked an id: {out}");
823        assert!(r.restricted);
824        assert_eq!(r.kind, "drawing");
825        assert_eq!(r.to_number, RESTRICTED_LABEL);
826        assert_eq!(r.to_item_id, Uuid::nil());
827    }
828
829    #[test]
830    fn masked_change_item_leaks_nothing() {
831        let mut c = change_item();
832        c.mask();
833        let out = json(&c);
834        assert!(!out.contains(SECRET), "leaked: {out}");
835        assert!(!out.contains(&id().to_string()), "leaked an id: {out}");
836        assert!(c.restricted);
837        assert_eq!(c.item_number, RESTRICTED_LABEL);
838        assert!(c.checked_out_by_name.is_none());
839    }
840
841    #[test]
842    fn masked_request_item_leaks_nothing_including_the_note() {
843        let mut r = request_item();
844        r.mask();
845        let out = json(&r);
846        assert!(!out.contains(SECRET), "leaked: {out}");
847        assert!(!out.contains(&id().to_string()), "leaked an id: {out}");
848        assert!(r.restricted);
849        assert_eq!(r.item_number, RESTRICTED_LABEL);
850        assert!(r.project_code.is_none() && r.revision_id.is_none());
851    }
852
853    #[test]
854    fn masking_twice_changes_nothing() {
855        let mut once = bom_line();
856        once.mask();
857        let mut twice = once.clone();
858        twice.mask();
859        assert_eq!(json(&once), json(&twice));
860    }
861
862    #[test]
863    fn project_ids_never_serialize() {
864        // the group id is only there for the read check
865        let line = bom_line();
866        let value: serde_json::Value = serde_json::from_str(&json(&line)).unwrap();
867        assert!(value.get("child_project_id").is_none());
868        let r = reference();
869        let value: serde_json::Value = serde_json::from_str(&json(&r)).unwrap();
870        assert!(value.get("to_project_id").is_none());
871        let i = impact(Some(id()));
872        let value: serde_json::Value = serde_json::from_str(&json(&i)).unwrap();
873        assert!(value.get("source_project_id").is_none());
874    }
875
876    #[test]
877    fn impact_of_a_hidden_source_is_redacted() {
878        let hidden = id();
879        let redacted = impact(Some(hidden)).redact(&actor(vec![hidden]));
880        let out = json(&redacted);
881        assert!(!out.contains(SECRET), "leaked: {out}");
882        assert!(redacted.source_restricted);
883        assert_eq!(redacted.source_number, RESTRICTED_LABEL);
884        assert!(redacted.change_id.is_none() && redacted.change_number.is_none());
885        // what the reader may know stays
886        assert_eq!(redacted.item_number, "DEP-000001");
887        assert_eq!(redacted.event, "released");
888        assert_eq!(redacted.relation, "bom");
889    }
890
891    #[test]
892    fn impact_of_a_visible_source_is_unchanged() {
893        let other = Uuid::from_u128(999);
894        for project in [None, Some(id())] {
895            let before = impact(project);
896            let after = before.clone().redact(&actor(vec![other]));
897            assert_eq!(json(&before), json(&after));
898            assert!(!after.source_restricted);
899        }
900    }
901
902    #[test]
903    fn hide_change_only_removes_the_change() {
904        let mut i = impact(None);
905        i.hide_change();
906        assert!(i.change_id.is_none() && i.change_number.is_none());
907        assert!(
908            json(&i).contains(&secret("number")),
909            "the source stays visible"
910        );
911        assert!(!i.source_restricted);
912    }
913
914    #[test]
915    fn project_restriction_follows_the_visibility_string() {
916        let mut p = Project {
917            id: id(),
918            code: "P".into(),
919            name: "P".into(),
920            description: String::new(),
921            active: true,
922            created_at: Utc::now(),
923            oidc_group: None,
924            member_count: 0,
925            created_by: None,
926            number_scheme: None,
927            visibility: ProjectVisibility::Internal.as_str().into(),
928        };
929        assert!(!p.is_restricted());
930        p.visibility = ProjectVisibility::Restricted.as_str().into();
931        assert!(p.is_restricted());
932        p.visibility = "Restricted".into();
933        assert!(!p.is_restricted(), "the comparison is exact");
934    }
935}