| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func env(pairs map[string]string) func(string) string { |
| 10 | return func(k string) string { return pairs[k] } |
| 11 | } |
| 12 | |
| 13 | // The roster, and every way a half-configured one must refuse to boot. |
| 14 | // |
| 15 | // The shape of every refusal here is the same: **the alternative is |
| 16 | // silence.** A sibling that cannot be asked withholds every passage it |
| 17 | // owns, so a missing token or a missing team does not produce an error a |
| 18 | // reader sees — it produces a page that is permanently short with a notice |
| 19 | // blaming an app that is fine. Refused at boot, that is a startup message. |
| 20 | // Left to query time, it is a mystery nobody can diagnose from the page. |
| 21 | // |
| 22 | // TWO-SIDED: the last case parses a good roster, or "refuses everything" |
| 23 | // satisfies every assertion above it. |
| 24 | // |
| 25 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 26 | // |
| 27 | // accept an entry with no token (empty Token in the struct) |
| 28 | // -> the no-token case red |
| 29 | // accept http:// as well as https:// |
| 30 | // -> the cleartext case red |
| 31 | // skip the duplicate check |
| 32 | // -> the duplicate case red |
| 33 | // default Team to "" and carry on |
| 34 | // -> the no-team case red |
| 35 | // return the apps unsorted |
| 36 | // -> the ordering assertion red |
| 37 | // require MEMORIA_TEAM even with no siblings |
| 38 | // -> the empty-roster case red, which is slice 0's whole config |
| 39 | // |
| 40 | // MUTATION-TESTED 2026-09-07, after a review found the host check missing: |
| 41 | // |
| 42 | // drop the CheckSiblingHost call |
| 43 | // -> the swapped-label case red |
| 44 | // key the duplicate check on the name instead of the token variable |
| 45 | // -> the case-collision case red |
| 46 | // echo the raw entry into the malformed-entry error |
| 47 | // -> the no-echo case red |
| 48 | func TestLoadSiblings(t *testing.T) { |
| 49 | t.Run("no siblings is a configuration, not a warning", func(t *testing.T) { |
| 50 | got, err := LoadSiblings(env(nil)) |
| 51 | if err != nil { |
| 52 | t.Fatalf("an instance with no siblings refused to boot: %v", err) |
| 53 | } |
| 54 | if len(got.Apps) != 0 { |
| 55 | t.Errorf("got %d apps from an empty roster", len(got.Apps)) |
| 56 | } |
| 57 | }) |
| 58 | |
| 59 | t.Run("a sibling with no token is refused", func(t *testing.T) { |
| 60 | _, err := LoadSiblings(env(map[string]string{ |
| 61 | "MEMORIA_TEAM": "acme", |
| 62 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works", |
| 63 | })) |
| 64 | if !errors.Is(err, ErrMissing) { |
| 65 | t.Fatalf("err = %v, want ErrMissing", err) |
| 66 | } |
| 67 | if !strings.Contains(err.Error(), "MEMORIA_SIBLING_TOKEN_DOCS") { |
| 68 | t.Errorf("the error does not name the variable to set: %v", err) |
| 69 | } |
| 70 | }) |
| 71 | |
| 72 | t.Run("a sibling on cleartext is refused", func(t *testing.T) { |
| 73 | _, err := LoadSiblings(env(map[string]string{ |
| 74 | "MEMORIA_TEAM": "acme", |
| 75 | "MEMORIA_SIBLINGS": "Docs=http://acme.docs.oficina.works", |
| 76 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 77 | "MEMORIA_SIBLING_TOKEN_SHEET": "t", |
| 78 | })) |
| 79 | if err == nil { |
| 80 | t.Error("a consumer token was allowed to travel over cleartext") |
| 81 | } |
| 82 | }) |
| 83 | |
| 84 | t.Run("a duplicate name is refused", func(t *testing.T) { |
| 85 | _, err := LoadSiblings(env(map[string]string{ |
| 86 | "MEMORIA_TEAM": "acme", |
| 87 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works,Docs=https://acme.sheets.oficina.works", |
| 88 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 89 | })) |
| 90 | if err == nil { |
| 91 | t.Error("two apps configured under one name: one's item ids would " + |
| 92 | "go to the other, and a conforming sibling answers NOT_VISIBLE " + |
| 93 | "for ids it has no row for — the value that may be silent") |
| 94 | } |
| 95 | }) |
| 96 | |
| 97 | t.Run("siblings without a team are refused", func(t *testing.T) { |
| 98 | _, err := LoadSiblings(env(map[string]string{ |
| 99 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works", |
| 100 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 101 | })) |
| 102 | if !errors.Is(err, ErrMissing) { |
| 103 | t.Fatalf("err = %v, want ErrMissing: without a team there is "+ |
| 104 | "nothing for the host guard to compare against", err) |
| 105 | } |
| 106 | }) |
| 107 | |
| 108 | t.Run("a good roster parses, or none of the above proves anything", func(t *testing.T) { |
| 109 | got, err := LoadSiblings(env(map[string]string{ |
| 110 | "MEMORIA_TEAM": "acme", |
| 111 | // **The names and the hosts sort in OPPOSITE orders**, on |
| 112 | // purpose: with Docs/Sheets against acme.docs/acme.sheets the |
| 113 | // two orderings are identical, so sorting by BaseURL passes — |
| 114 | // a review's mutation did. Here "Archive" sorts first by name |
| 115 | // and its host sorts last. |
| 116 | "MEMORIA_SIBLINGS": " Sheets=https://acme.sheets.oficina.works/ , Archive=https://acme.zzz.oficina.works ", |
| 117 | "MEMORIA_SIBLING_TOKEN_ARCHIVE": "archive-token", |
| 118 | "MEMORIA_SIBLING_TOKEN_SHEETS": "sheets-token", |
| 119 | })) |
| 120 | if err != nil { |
| 121 | t.Fatalf("a good roster was refused: %v", err) |
| 122 | } |
| 123 | if got.Team != "acme" { |
| 124 | t.Errorf("team = %q, want acme", got.Team) |
| 125 | } |
| 126 | // Sorted, so a log line and a page's notices do not depend on the |
| 127 | // order somebody happened to type the variable in. |
| 128 | if len(got.Apps) != 2 || got.Apps[0].Name != "Archive" || got.Apps[1].Name != "Sheets" { |
| 129 | t.Fatalf("apps = %+v, want Archive then Sheets — sorted by NAME, "+ |
| 130 | "which is what a reader sees; these hosts sort the other way", got.Names()) |
| 131 | } |
| 132 | if got.Apps[0].Token != "archive-token" || got.Apps[1].Token != "sheets-token" { |
| 133 | t.Error("a token went to the wrong app, which would send one " + |
| 134 | "sibling's credential to another") |
| 135 | } |
| 136 | // The trailing slash is trimmed, or every route becomes |
| 137 | // `//suite/s1` — which some routers serve and some 404. |
| 138 | if got.Apps[1].BaseURL != "https://acme.sheets.oficina.works" { |
| 139 | t.Errorf("base url = %q, want no trailing slash", got.Apps[1].BaseURL) |
| 140 | } |
| 141 | }) |
| 142 | |
| 143 | t.Run("an address the transport will refuse is refused at boot", func(t *testing.T) { |
| 144 | // **`<team>.<app>`, team first** (§6.3). This file's own example |
| 145 | // had the labels the other way round, and nothing checked, so the |
| 146 | // roster accepted an address every S1, S2 and S3 would then be |
| 147 | // refused for — before dialling, so not even a timeout. The |
| 148 | // instance boots, logs "siblings configured", and answers every |
| 149 | // query with "Docs did not answer; N results withheld" about an |
| 150 | // app that is working perfectly. |
| 151 | // |
| 152 | // That is precisely the failure this whole function exists to |
| 153 | // turn into a boot error, arriving through the one field nobody |
| 154 | // was checking. Found by a review, 2026-09-07. |
| 155 | _, err := LoadSiblings(env(map[string]string{ |
| 156 | "MEMORIA_TEAM": "acme", |
| 157 | "MEMORIA_SIBLINGS": "Docs=https://docs.acme.oficina.works", |
| 158 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 159 | })) |
| 160 | if err == nil { |
| 161 | t.Error("a swapped <app>.<team> address was accepted; every call to " + |
| 162 | "it would be refused before the transport dialled, forever, " + |
| 163 | "and the page would blame the sibling") |
| 164 | } |
| 165 | |
| 166 | for _, bad := range []string{ |
| 167 | "https://acme.docs.example.com", // outside the suite domain |
| 168 | "https://acme.docs.oficina.works.x", // suffix attack |
| 169 | "https://elsewhere.docs.oficina.works", |
| 170 | "https://acme.oficina.works", // no app label |
| 171 | } { |
| 172 | if _, err := LoadSiblings(env(map[string]string{ |
| 173 | "MEMORIA_TEAM": "acme", |
| 174 | "MEMORIA_SIBLINGS": "Docs=" + bad, |
| 175 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 176 | })); err == nil { |
| 177 | t.Errorf("accepted %q", bad) |
| 178 | } |
| 179 | } |
| 180 | }) |
| 181 | |
| 182 | t.Run("two names that resolve to one token variable are refused", func(t *testing.T) { |
| 183 | // "Docs" and "docs" are different map keys and different |
| 184 | // Document.Source values, so the name check passed them — and |
| 185 | // they read the SAME MEMORIA_SIBLING_TOKEN_DOCS, so one app |
| 186 | // silently receives the other's credential. Found by a review. |
| 187 | _, err := LoadSiblings(env(map[string]string{ |
| 188 | "MEMORIA_TEAM": "acme", |
| 189 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works,docs=https://acme.sheets.oficina.works", |
| 190 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 191 | })) |
| 192 | if err == nil { |
| 193 | t.Error("two apps resolving to one token variable were accepted; " + |
| 194 | "one would be sent the other's consumer token") |
| 195 | } |
| 196 | }) |
| 197 | |
| 198 | t.Run("a malformed entry is named by position, never echoed", func(t *testing.T) { |
| 199 | // main logs this error, and MEMORIA_SIBLINGS sits beside the |
| 200 | // token variables in the same config. One transposed assignment |
| 201 | // and the "malformed entry" is a bearer token in `carlos logs`, |
| 202 | // which is storage we cannot delete. |
| 203 | _, err := LoadSiblings(env(map[string]string{ |
| 204 | "MEMORIA_TEAM": "acme", |
| 205 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works,sk-live-not-a-roster-entry", |
| 206 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 207 | })) |
| 208 | if err == nil { |
| 209 | t.Fatal("a malformed entry was accepted") |
| 210 | } |
| 211 | if strings.Contains(err.Error(), "sk-live-not-a-roster-entry") { |
| 212 | t.Errorf("the error echoed the entry: %v", err) |
| 213 | } |
| 214 | if !strings.Contains(err.Error(), "entry 2") { |
| 215 | t.Errorf("the error does not say which entry: %v", err) |
| 216 | } |
| 217 | }) |
| 218 | |
| 219 | t.Run("a display name maps to one token variable", func(t *testing.T) { |
| 220 | // One spelling, so an app cannot be configured two ways and the |
| 221 | // operator cannot set the variable that is never read. |
| 222 | for _, c := range []struct{ name, want string }{ |
| 223 | {"Docs", "MEMORIA_SIBLING_TOKEN_DOCS"}, |
| 224 | {"Team Docs", "MEMORIA_SIBLING_TOKEN_TEAM_DOCS"}, |
| 225 | {"sheets-2", "MEMORIA_SIBLING_TOKEN_SHEETS_2"}, |
| 226 | } { |
| 227 | if got := tokenVar(c.name); got != c.want { |
| 228 | t.Errorf("tokenVar(%q) = %q, want %q", c.name, got, c.want) |
| 229 | } |
| 230 | } |
| 231 | }) |
| 232 | } |
| 233 | |
| 234 | // A roster must never be printed with its tokens in it. |
| 235 | // |
| 236 | // Names() exists so nobody reaches for %+v on the config, and this asserts |
| 237 | // what it leaves out. §3.2's leak list and AGENTS.md's rule about logs: |
| 238 | // `carlos logs` is platform-held storage we cannot grep, so a token that |
| 239 | // reaches it is a credential that cannot be retracted — the same failure |
| 240 | // as the sign-in links gate 4 was written for. |
| 241 | func TestTheRosterCanBeLoggedWithoutItsTokens(t *testing.T) { |
| 242 | got, err := LoadSiblings(env(map[string]string{ |
| 243 | "MEMORIA_TEAM": "acme", |
| 244 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works", |
| 245 | "MEMORIA_SIBLING_TOKEN_DOCS": "super-secret-token", |
| 246 | })) |
| 247 | if err != nil { |
| 248 | t.Fatalf("LoadSiblings: %v", err) |
| 249 | } |
| 250 | // The presence half first: the token IS there to leak, so the absence |
| 251 | // below is an assertion rather than a description of an empty world. |
| 252 | if got.Apps[0].Token != "super-secret-token" { |
| 253 | t.Fatal("the fixture has no token in it, so the check below proves nothing") |
| 254 | } |
| 255 | for _, n := range got.Names() { |
| 256 | if strings.Contains(n, "super-secret-token") { |
| 257 | t.Errorf("Names() carried a token: %q", n) |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |