01 / apply it
yardr pack apply ./infomaniak-calendarFrom a clone of yardr-packs: the pack's directory. yardr pack check ./infomaniak-calendar shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/infomaniak-calendar/From this site, as the files below.
02 / the files
Every file, as it ships.
# Infomaniak Calendar as a pack of kind source: the integration, imported
# into the yard and into no depot. What it is here: the declared source
# infomaniak-calendar, which lists the calendars, lists a calendar's events
# in a window, reads one event, and creates and edits timed events without
# inviting anyone, all through the calendar API behind Infomaniak's own
# calendar app. It is the twin of google-calendar (in the yardr repository),
# with that pack's call names and arguments wherever the service allows;
# README.md says where it does not.
#
# yardr pack apply /Users/mckean/projects/repos/yardr-packs/infomaniak-calendar
#
# The source is named infomaniak-calendar: a pack is named after the service
# it talks to. Its credential is a token made in Infomaniak's manager, typed
# unseen at setup, so nothing is minted. A pack of kind source asks no grants
# at import, so what a crew member may call is granted afterwards, by the
# setup notes. Every agent granted a call acts as the one Infomaniak user the
# token was made for.
name: infomaniak-calendar
kind: source
setup: SETUP.md
sources:
infomaniak-calendar:
declaration: infomaniak-calendar.yaml
# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and a
# terminal pack apply ignores them. read covers every read call and no
# update; each update is granted by its name, so create and edit are apart.
presets:
read:
about: List the calendars, list a calendar's events and read one event; change nothing
grants:
infomaniak-calendar: [read]
create:
about: List and read events, and create timed ones; no invitations go out
grants:
infomaniak-calendar: [read, create]
everything:
about: List, read, create and edit events; no invitations go out
grants:
infomaniak-calendar: [read, create, edit]
#!/bin/bash
# build-edit: the edit call's request body. The service's update is a PUT of
# the whole event, not a patch, so the body is the event as it is now with
# the fields the call gave changed and everything else sent back as it was:
# a PUT without location and color loses them (Infomaniak's own client
# tests say so), and so would one without the attendees or the alarms. One
# JSON object on stdin, {"params": {eventId, and any of summary, start, end,
# description, location}}, one on stdout, the PUT body. The event is read
# first, with the yard's credential: INFOMANIAK_CALENDAR_TOKEN and
# YARDR_SOURCE_BASE, the declaration's base, are in the environment. Needs
# curl and jq.
#
# Only a given field changes. The event keeps its calendar, its attendees
# and its zones, and the body sets notifyAttendees and imip_request false,
# so no mail goes about the change: an event it edits invites no one. A
# start or end given is a wall clock, 'YYYY-MM-DD HH:MM:SS', in the zone the
# event has (timezone_start for the start, timezone_end for the end), which
# is the service's own meaning of it. What the event has that is read back
# as the service's client reads it: start and end come back with a T and an
# offset or a space and none, and go out as the wall clock, 19 characters.
#
# It refuses, in words and with nothing sent, a call that gives nothing to
# change; an event it cannot read; an event with no timezone, since the body
# needs one and the profile's is not read here; an all-day event whose times
# are given, which is the service's other shape of start and end; and an
# event that recurs, whose PUT would change the series where the twin's
# patch changes one occurrence. Where the service answers 4xx to the PUT
# itself, the yard shows that.
set -euo pipefail
in=$(cat)
refuse() { echo "infomaniak-calendar/build-edit: $1" >&2; exit 1; }
for tool in curl jq; do
command -v "$tool" >/dev/null 2>&1 || refuse "needs $tool on PATH"
done
[ -n "${INFOMANIAK_CALENDAR_TOKEN:-}" ] || refuse "INFOMANIAK_CALENDAR_TOKEN is not set"
[ -n "${YARDR_SOURCE_BASE:-}" ] || refuse "YARDR_SOURCE_BASE is not set"
case "$INFOMANIAK_CALENDAR_TOKEN" in *$'\n'*|*$'\r'*) refuse "INFOMANIAK_CALENDAR_TOKEN holds a line break";; esac
params=$(jq -c 'if (.params | type) == "object" then .params else empty end' <<<"$in" 2>/dev/null || true)
[ -n "$params" ] || refuse 'want {"params": {...}} on stdin'
given() { jq -e --arg k "$1" 'has($k)' <<<"$params" >/dev/null; }
# The id goes into a path: the declaration holds it to a number of at most 15
# digits, and the program does not rely on that.
id=$(jq -r '.eventId // ""' <<<"$params")
case "$id" in
''|0*|*[!0-9]*) refuse "eventId is no event number";;
esac
[ "${#id}" -le 15 ] || refuse "eventId is no event number"
changes=""
for p in summary start end description location; do
if given "$p"; then changes="$changes$p "; fi
done
[ -n "$changes" ] || refuse "nothing to change: give at least one of summary, start, end, description and location"
# curl takes the token as a config line on its stdin: an argument can be read
# by every process on the machine while curl runs.
curl_quote() {
local value=$1
value=${value//\\/\\\\}
value=${value//\"/\\\"}
printf '"%s"' "$value"
}
base=${YARDR_SOURCE_BASE%/}
code=0
out=$(
{
printf 'url = '
curl_quote "$base/1/calendar/pim/event/$id"
printf '\nheader = '
curl_quote "Authorization: Bearer $INFOMANIAK_CALENDAR_TOKEN"
printf '\nheader = "Accept: application/json"\n'
} | curl -s --fail-with-body --max-time 20 -K -
) || code=$?
if [ "$code" != 0 ]; then
detail=$(jq -r '.error.description // .error.code // empty' <<<"$out" 2>/dev/null || true)
[ -n "$detail" ] || detail="curl exit $code"
refuse "event $id could not be read: $detail"
fi
event=$(jq -c 'if (.data | type) == "object" then .data else empty end' <<<"$out" 2>/dev/null || true)
[ -n "$event" ] || refuse "event $id could not be read: the answer has no event in data"
# What the body needs of the event, or what makes this call not one to send.
zone_missing=$(jq -r '[(if (.timezone_start | type) == "string" and .timezone_start != "" then empty else "timezone_start" end),
(if (.timezone_end | type) == "string" and .timezone_end != "" then empty else "timezone_end" end)] | join(" and ")' <<<"$event")
[ -z "$zone_missing" ] || refuse "event $id has no $zone_missing: the body needs the zone, and the account's own is not read here"
if jq -e '.fullday == true' <<<"$event" >/dev/null && { given start || given end; }; then
refuse "event $id is an all-day event: its times are not changed here, only its title, description and location"
fi
if jq -e '(.rrule // "") != ""' <<<"$event" >/dev/null; then
refuse "event $id recurs: a change would be to the whole series, and a recurring event is only read here"
fi
for f in start end; do
if ! given "$f" && ! jq -e --arg k "$f" '.[$k] | type == "string"' <<<"$event" >/dev/null; then
refuse "event $id has no $f to send back"
fi
done
jq -c -n --argjson p "$params" --argjson ev "$event" --arg id "$id" '
# The wall clock of a start or end as the service answers it: a T or a
# space, and an offset or none; 19 characters of it go back.
def wall: sub("T"; " ") | sub("\\+.*$"; "") | sub("Z"; "") | .[0:19];
def given($k; $kept): if ($p | has($k)) then $p[$k] else $kept end;
given("location"; $ev.location) as $location
| {
id: ($id | tonumber),
calendar_id: $ev.calendar_id,
title: given("summary"; $ev.title),
description: given("description"; ($ev.description // "")),
start: given("start"; ($ev.start | wall)),
end: given("end"; ($ev.end | wall)),
timezone_start: $ev.timezone_start,
timezone_end: $ev.timezone_end,
freebusy: ($ev.freebusy // "busy"),
type: ($ev.type // "event"),
fullday: ($ev.fullday // false),
private: ($ev.private // false),
color: ($ev.color // null),
attendees: ($ev.attendees // []),
alarms: ($ev.alarms // []),
attachments: ($ev.attachments // []),
rrule: "",
meet_room_url: ($ev.meet_room_url // ""),
notifyAttendees: false,
parent_updated: false,
imip_request: false
}
+ (if ($location | type) == "string" and $location != "" then {location: $location} else {} end)
+ (if ($ev | has("bookable_resource_id")) then {bookable_resource_id: $ev.bookable_resource_id} else {} end)'
# Infomaniak Calendar as a declared source: the calendars listed, a
# calendar's events listed in a window and one read, and a timed event
# created and edited without inviting anyone, through the calendar API under
# /1/calendar/pim at api.infomaniak.com. It is the twin of google-calendar
# (packs/google-calendar in the yardr repository) and keeps that file's call
# names (events, event, calendars, create, edit) and arguments (calendarId,
# timeMin, timeMax, eventId, summary, start, end, description, location)
# where the service has a counterpart. What it never does is send an
# invitation: an event it creates has no attendees and notifyAttendees
# false, and one it edits keeps the attendees it had and sends no mail about
# the change. Nor does it delete or move an event, book a room, set a
# recurrence, or read the address book.
#
# Checked against Infomaniak's own MCP server for its calendar
# (github.com/Infomaniak/mcp-server-calendar, main at 358a61c, whose last
# commit is of 3 November 2026), read on 10 November 2026:
# src/calendar-client.ts, src/index.ts, README.md, tests/verify.mjs,
# tests/timezone.test.mjs and tests/fixtures/timezone-probe.mjs. These
# routes are in no page of developer.infomaniak.com, so that client is the
# only source, and nothing was sent to the service: README.md, "What was
# checked", says what is pinned by fixtures shaped after that client and
# what is not tried. What the client does that this file relies on:
#
# GET /1/calendar/pim/calendar answers {result, data: {calendars}}, each
# calendar with an id (a number in its tests), a name and a default
# flag. Its client picks the default calendar itself when none is
# named, so a declared call cannot: calendarId is always given.
# GET /1/calendar/pim/event takes calendar_id, from and to and answers
# {result, data: [events]}. from and to are a wall clock,
# 'YYYY-MM-DD HH:MM:SS', in the account's profile timezone.
# GET /1/calendar/pim/event/{id} answers {result, data: event}.
# POST /1/calendar/pim/event takes {title, start, end, description,
# freebusy, type, calendar_id, fullday, timezone_start, timezone_end,
# attendees, notifyAttendees} (and rrule, left out here) and answers
# {result, data}. Its start and end are the same wall clock, and the
# zone is a name in timezone_start and timezone_end.
# PUT /1/calendar/pim/event/{id} is the update: the whole event, sent
# again with the change, and no patch. Its client reads the event first
# and sends every field back (id, calendar_id, title, description,
# start, end, timezone_start, timezone_end, freebusy, type, fullday,
# private, location where there is one, color, attendees, alarms,
# attachments, rrule, meet_room_url, bookable_resource_id,
# notifyAttendees, parent_updated, imip_request), and its tests say a
# PUT that left out location and color lost them.
# Headers: Authorization: Bearer <token> and Content-Type
# application/json. The scopes its README asks for are
# workspace:calendar and user_info.
#
# Where google-calendar has no counterpart, the file leaves it out:
#
# q and maxResults: the client sends neither, and takes the one answer
# the service gives, with no page after it (no nextPageToken), so no
# paging is declared. Not tried: whether a long window is cut. A window
# too big for the answer's bound is refused, not cut: ask for less.
# calendarId of primary, or an address: a calendar is the id calendars
# lists, and the one marked default there is the account's own.
# calendarId on event and edit: their routes name the event alone, and
# edit never moves an event to another calendar.
# attendees and sendUpdates: nobody is invited, as in the twin.
# htmlLink: nothing the client reads is a link to the event.
# all-day events and recurrence, as in the twin: an event created is
# timed and recurs never. edit refuses an event that recurs, and the
# times of an all-day one, which build-edit's head says the reasons of.
#
# Where the service differs in shape, the file says what is sent and keeps
# the service's own answer (the key is data, not items, and an event is the
# service's: title, start and end as text, timezone_start, attendees with an
# address and a state, and the rest the client names). Each answer keeps
# result and error beside data, so that a failure the service answers
# with a 200 is seen and no write reads as done with an empty answer; a
# 4xx or 5xx is refused by the yard, which shows its status alone:
#
# Times. The service takes a wall clock and the account's profile
# timezone decides what it means. Its client turns any value that
# carries an offset into that wall clock first, from GET /2/profile,
# which a declared call cannot do. So timeMin, timeMax, start and end
# take 'YYYY-MM-DD HH:MM:SS' alone, and an RFC 3339 value is refused
# before anything is sent. A created event also names its zone, which
# the body holds as timezone_start and timezone_end, so create takes
# timezone, an IANA name, as a required argument. An edited event keeps
# the zones it has, and the wall clock given to edit is in them.
# edit. The update being a PUT of the whole event, a body of the given
# fields alone would take the rest out of the event. So edit names a
# build command, infomaniak-calendar/build-edit, beside this file: it
# reads the event with the credential, changes only what was given,
# sends attendees, alarms, location and the rest back as they were, and
# sets notifyAttendees and imip_request false. An edit is therefore two
# requests to the service, a GET and the PUT. It refuses, in words and
# with nothing sent, a call that gives nothing to change, an event it
# cannot read, an event with no timezone, the times of an all-day
# event, and an event that recurs.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
#
# Limits. Infomaniak allows 60 requests a minute (the figure of the proposal
# these packs come from, which read it in the developer portal; the portal
# needs a script to show its pages, and was not read again). yardr
# holds a caller to 60 calls an hour of a source and 10 updates, which is
# stricter: SETUP.md says how to raise it. The service has no webhook for
# the calendar, so the file has no hook.
#
# The service takes no idempotency key as far as its client shows, so the
# file names none: an event created again after a lost answer is made twice.
#
# The name is infomaniak-calendar: a pack is named after the service it
# talks to.
name: infomaniak-calendar
about: Infomaniak Calendar events listed, read, created and edited without inviting anyone; calendars listed.
# The name of the variable, never the value. A token made in Infomaniak's
# manager with the scopes workspace:calendar and user_info, the pair the MCP
# server's README asks for. Nothing in this file calls the profile, so which
# of the two the calendar routes need is not tried. The token lasts until it
# is revoked, and is switched off after a year unused, so no mint is named.
credential:
env: INFOMANIAK_CALENDAR_TOKEN
header: "Authorization: Bearer {credential}"
# ---- what an agent may ask of it, through `yardr call` --------------------
# Declared requests, granted one by one; edit's body is built by a command,
# which is never something an agent calls itself.
#
# base is the API's one host, and every path starts with /1/calendar/pim,
# the prefix of the calendar routes. calendars is the check call: it takes
# no argument, so `yardr source check infomaniak-calendar` shows at once
# whether the token is one the service takes and holds the calendar scope.
base: https://api.infomaniak.com
check: calendars
calls:
calendars:
kind: read
about: List the calendars, each with the id the other calls take as calendarId
request:
method: GET
path: /1/calendar/pim/calendar
answer: { select: [result, data, error] } # data.calendars, each with its id, its name and its default flag; result and error are the service's own, so a failure it answers with a 200 shows
events:
kind: read
about: >-
List a calendar's events in a window, in one answer. timeMin and timeMax
are a wall clock in the account's own timezone, 'YYYY-MM-DD HH:MM:SS',
with no offset and no T: Infomaniak reads them so, and an RFC 3339 value
is refused before anything is sent. calendarId is an id calendars lists.
params:
calendarId: { type: string, pattern: "^[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*$", max: 64, log: true } # the id calendars lists, given always (the default is picked by the service's client). A number or a text, with a digit in it: that keeps out google-calendar's primary
timeMin: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" } # a wall clock in the account's own timezone, 2026-10-20 00:00:00: no offset, no T
timeMax: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
request:
method: GET
path: /1/calendar/pim/event
query: { calendar_id: "{calendarId}", from: "{timeMin}", to: "{timeMax}" }
answer: { select: [result, data, error], max: 262144 } # data is the list of events; the bound is the twin's
event:
kind: read
about: Read one event, by the id events lists
params:
eventId: { type: string, pattern: "^[1-9][0-9]{0,14}$", max: 15, log: true } # the id events lists: a number, which edit sends back as one
request:
method: GET
path: /1/calendar/pim/event/{eventId}
answer: { select: [result, data, error] }
create:
kind: update
about: >-
Create a timed event, inviting no one. start and end are a wall clock,
'YYYY-MM-DD HH:MM:SS', in timezone, an IANA name such as Europe/Zurich.
calendarId is an id calendars lists.
params:
calendarId: { type: string, pattern: "^[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*$", max: 64, log: true }
summary: { type: string, max: 500 } # the event's title, free text: its value is in no event
start: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" } # a wall clock in the zone given below, 2026-10-24 12:00:00
end: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" } # past start, else the service refuses the event
timezone: { type: string, pattern: "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$", max: 64 } # an IANA name, Europe/Zurich: the account's own is in its profile, and any event's timezone_start shows it
description: { type: string, max: 10000, optional: true } # free text, left out, no description
location: { type: string, max: 500, optional: true } # free text, left out, nowhere. The client's create never sends one, and its update does, so the field is the service's: not tried on a create
request:
method: POST
path: /1/calendar/pim/event
body: { calendar_id: "{calendarId}", title: "{summary}", start: "{start}", end: "{end}", timezone_start: "{timezone}", timezone_end: "{timezone}", description: "{description}", location: "{location}", type: event, freebusy: busy, fullday: false, attendees: [], notifyAttendees: false } # a description or location left out leaves the key; no attendees, and notifyAttendees false, so no mail goes
answer: { select: [result, data, error] }
edit:
kind: update
about: >-
Change an event, only what is given: it is read, changed and sent back
whole, so its attendees, alarms and the rest stay as they were. start and
end are a wall clock in the event's own timezone. An event that recurs is
refused, and so are the times of an all-day one.
params:
eventId: { type: string, pattern: "^[1-9][0-9]{0,14}$", max: 15, log: true }
summary: { type: string, max: 500, optional: true } # free text; left out, the title stays
start: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", optional: true } # a wall clock in the event's own timezone; left out, the beginning stays
end: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", optional: true }
description: { type: string, max: 10000, optional: true }
location: { type: string, max: 500, optional: true }
request:
method: PUT
path: /1/calendar/pim/event/{eventId}
build: [infomaniak-calendar/build-edit] # reads the event, merges what was given, prints the whole PUT body; refuses a call that gives nothing to change
answer: { select: [result, data, error] }
# Infomaniak Calendar
Infomaniak Calendar as a pack of kind source, for a yard whose crew reads and
writes a calendar there. An agent lists the calendars, lists a calendar's events
in a window, reads one event, and creates and edits timed events without
inviting anyone. It is the twin of `google-calendar` (in the yardr repository):
the same call names, and the same arguments where Infomaniak has a counterpart.
The yard acts as one Infomaniak user, with a token made in the manager and typed
unseen. Nothing here is compiled into yardr; apply it with:
```sh
yardr pack apply /path/to/yardr-packs/infomaniak-calendar
```
SETUP.md is the operator's steps. **Nothing in this pack has been tried against
Infomaniak's service**: there was no account to try it on. "What was checked, and
what was not" says what the tests hold and what is left for the first live run.
## The calls
All five are declared in `infomaniak-calendar.yaml`, against
`https://api.infomaniak.com`. The routes are those of Infomaniak's own MCP server
for its calendar, which is the only place they are written down: they are not in
developer.infomaniak.com.
| call | kind | request | arguments |
|---|---|---|---|
| `calendars` | read | `GET /1/calendar/pim/calendar` | none. The check call |
| `events` | read | `GET /1/calendar/pim/event?calendar_id&from&to` | `calendarId`, `timeMin`, `timeMax` |
| `event` | read | `GET /1/calendar/pim/event/{eventId}` | `eventId` |
| `create` | update | `POST /1/calendar/pim/event` | `calendarId`, `summary`, `start`, `end`, `timezone`; `description`, `location` optional |
| `edit` | update | `GET`, then `PUT /1/calendar/pim/event/{eventId}` | `eventId`; any of `summary`, `start`, `end`, `description`, `location` |
Every answer keeps the service's `result` and `data`, and its `error` where it
sends one, so that a failure it answers with a 200 is seen and no write reads as
done with an empty answer (a 4xx or 5xx is refused by the yard, which shows the
status alone). `data` is, for `calendars`, `{calendars: [{id, name, default}]}`;
for `events` the list of events; for the others one event or what the service
answered. An event is the service's own, with the fields its client
names: `id`, `calendar_id`, `title`, `description`, `start` and `end` as text,
`timezone_start` and `timezone_end`, `location`, `color`, `freebusy`, `type`,
`fullday`, `private`, `attendees` (each with `address`, `name`, `state`,
`organizer`), `alarms`, `attachments`, `rrule`, `meet_room_url` and
`bookable_resource_id`. A title, a description or a location is text somebody
else may have written (an invitation is one), so `yardr call` prints each
answer as data from outside the yard, not as instructions.
**Times.** The service takes a wall clock, `YYYY-MM-DD HH:MM:SS`, and what it
means is the account's profile timezone. Its client turns any value with an
offset into that wall clock first, from the profile (`GET /2/profile`), and a
declared call cannot. So `timeMin`, `timeMax`, `start` and `end` take that form
alone: no `T`, no offset. An RFC 3339 value is refused before anything is sent,
with the pattern in the refusal. `events` reads the window in the account's own
timezone. `create` takes `timezone`, an IANA name such as `Europe/Zurich`,
because the body names the zone (`timezone_start` and `timezone_end`); the
account's own zone is in its profile, and any event's `timezone_start` shows it.
`edit` keeps the zones the event has, and the wall clock it is given is in them.
**`edit`.** The service's update is a PUT of the whole event, and no patch: its
client reads the event, changes it, and sends every field back, and its tests say
that a PUT without `location` and `color` lost them. A declared body cannot read
first, so `edit` names a build command, `infomaniak-calendar/build-edit`, as
`google-mail`'s drafts name `build-draft`. It reads the event with the token,
changes only the fields the call gave, sends the attendees, the alarms, the
location, the colour and the rest back as they were, and sets `notifyAttendees`
and `imip_request` false, so no mail goes about the change. An edit is therefore
two requests to Infomaniak. It refuses, in words and with nothing sent:
- a call that gives nothing to change;
- an event it cannot read (the service's reason is shown when it gives one);
- an event with no timezone, because the body needs one and the profile's is not
read here;
- the times of an all-day event, whose start and end are the service's other
shape: its title, description and location can still be changed;
- an event that recurs, because a PUT of it would change the series where
`google-calendar`'s patch changes one occurrence, and no call here names an
occurrence.
## Against google-calendar
Where the twin has no counterpart here, the declaration leaves it out and says so
in its head comment. A role written for `google-calendar` needs these changes:
| `google-calendar` | here |
|---|---|
| `calendarId=primary`, or a calendar's address | the id `calendars` lists, which has a digit in it; the calendar marked `default` there is the account's own, and `primary` is refused |
| `calendarId` on `event` and `edit` | not taken: their routes name the event alone, and `edit` never moves an event to another calendar |
| `timeMin`, `timeMax` as RFC 3339 with a zone | `YYYY-MM-DD HH:MM:SS`, in the account's timezone |
| `start`, `end` as RFC 3339 with a zone | `YYYY-MM-DD HH:MM:SS`, with `timezone` on `create`, and the event's own zone on `edit` |
| `q`, `maxResults` on `events`, and a page of at most 50 | not there: the client sends neither and reads the one answer the service gives, so no paging is declared. A window too big for the answer's bound (256 KB) is refused, not cut: ask for less |
| an answer of `{items, nextPageToken}`, and events with `summary`, `start.dateTime`, `attendees[].email`, `htmlLink` | `{result, data}`, and the service's own events: `title`, `start` as text, `attendees[].address` and `state`; no link to the event |
| `edit` patches the fields given | `edit` reads the event and sends it whole, changed in the fields given |
| `sendUpdates=none` | no attendees on a created event, `notifyAttendees` false, and `imip_request` false on an edit |
| recurring events listed as instances | the service's events as it answers them; a recurring event is read and never edited here |
## What it never does
Invite anyone: `create` sends `attendees: []` and `notifyAttendees: false`, and
names no parameter for either, and `edit` sends no mail. Delete or move an event,
share a calendar, book a room, set a recurrence, create an all-day event, search,
page past one answer, or read the address book. The declaration's calls are the
five above; the yard's source format cannot express a call it does not list, and
the pack does not change yardr. The tests hold the five as data: no `DELETE`, no
`PATCH`, nothing outside `/1/calendar/pim`.
## Files
- `pack.yaml`: the pack, kind source, and the presets `read`, `create` and
`everything`, as the twin has them. A pack applied from a directory takes no
`--preset` grant, so SETUP.md grants each call by its name and the presets only
show in `yardr pack show`.
- `infomaniak-calendar.yaml`: the declaration, and the record of what was read and
when.
- `infomaniak-calendar/build-edit`: the program `edit` names.
- `SETUP.md`: the operator's steps, from the token to a first call.
- `test` and `testdata/`: see Testing.
## Runtimes
`edit` runs `infomaniak-calendar/build-edit` as the yard's user, with `bash`,
`curl` and `jq` on the daemon's `PATH`. The token reaches curl as a config line on
its stdin, never in an argument. Nothing else in the pack is a program: the other
four calls are declared requests, which the yard makes itself.
## Testing
`./test` needs `jq`, `curl`, `python3` and `yardr`, no network and no secret, and
takes a few seconds. The gate runs it beside `yardr pack check`.
- `build`: `build-edit` by itself against a stand-in for the service on a
loopback port (`testdata/standin.py`), one `testdata/build/*.case` each: the
event it reads, the body it prints, and each refusal. It never makes a request
but a GET, and the token is in no output of it.
- `core`: the real yardr in a scratch yard. The declaration as the yard reads it,
the grants SETUP.md teaches (read out of SETUP.md and run as written), the check
call, and one `yardr call` for each `testdata/calls/*.case` against the same
stand-in. A golden holds the requests the stand-in saw (method, path and query
as they arrived, and the body of a write), the status, what the call kept of
the answer, and what it said on stderr.
- The token is made for the run, from random bytes, and thrown away: the stand-in
says only whether a request carried it, and the test checks that it is in the
env file the yard wrote and in no other file or output.
`UPDATE_GOLDEN=1 ./test` writes the goldens again from what the programs print;
read the diff before keeping it.
## What was checked, and what was not
Read on 10 November 2026: Infomaniak's MCP server for its calendar
(`github.com/Infomaniak/mcp-server-calendar`, main at `358a61c`, whose last
commit is of 3 November 2026), namely `src/calendar-client.ts`, `src/index.ts`, `README.md`,
`tests/verify.mjs`, `tests/timezone.test.mjs` and `tests/fixtures/timezone-probe.mjs`;
and Infomaniak's guide to API tokens (FAQ 2582). Nothing was sent to the service.
Checked offline, by `./test` and the gate:
- that the declaration reads, applies, and sends what it says: the method, the path,
the query (the wall clock as `from=2026-10-20+00%3A00%3A00`) and the body of each
call, with the token as a bearer, and refuses what its patterns refuse before
anything is sent (an RFC 3339 time, `primary`, an attendee, a delete);
- that `edit` reads and then writes, and what it writes: only the given fields
changed, the event's attendees, alarms, location, colour and zones sent back, and
nobody notified;
- the grants SETUP.md teaches, the check call, and `source check` finding
`edit`'s program;
- that `yardr bead ask ... --secret infomaniak-calendar` and the grant card
`--run "source grant infomaniak-calendar ..."` are taken, and that
`--setup infomaniak-calendar` is refused, with yardr build `f17535e92091` in a
scratch yard (SETUP.md step 3 says why).
The fixtures in `testdata/answers` are shaped after that client's code and tests,
and are **not recorded from the service**. The client shows the fields it reads and
sends, so those are in them; what it does not show is made up where a test needs a
value (an alarm's members, the answer of a POST and of a PUT, the shape of an
error). The test therefore holds the pack to the client's reading of the service,
not to the service.
Not tried, and what to look at in the first live run with a token, in the order
of what would break most:
- **`edit`.** That the service takes the PUT body `build-edit` makes, and keeps
what the body sends back; that `notifyAttendees: false` and `imip_request: false`
send nothing to an event's attendees; what it answers; and what an event of
another account's, or a read-only calendar's, does to it.
- **Times.** That `from` and `to` as `YYYY-MM-DD HH:MM:SS` are read in the profile
timezone as the client's README says, and that a created event shows at the time
and in the zone given. Whether the service would take an RFC 3339 value, which
would let the pack take the twin's form.
- **The shapes.** Calendar ids (the client's delete handles one that is a number or a
text; the pattern takes both, with a digit in it), the fields of an event, what `calendars`
answers beyond `id`, `name` and `default`, whether `location` is taken on a create
(the client's create never sends one and its update does), and the error body
(`build-edit` reads `error.description`, which is Infomaniak's usual shape and
not one the client shows).
- **Windows.** Whether a long window is cut or paged, and how recurring events are
listed: as instances or as their parent.
- **The token.** That `workspace:calendar` alone is enough or `user_info` is needed
(no call here reads the profile; both are asked for, as the MCP server's README
says); and what a 401 and a 403 look like. The manager link in [Infomaniak's
FAQ 2582](https://www.infomaniak.com/en/support/faq/2582) resolves to
`https://manager.infomaniak.com/v3/ng/profile/user/token/list` (checked
10 November 2026); the manager's contents require a login. The MCP server's
README names `.../accounts/token/list` instead, which was not reachable without
signing in.
- **The limit.** 60 requests a minute is the figure of the proposal these packs come
from, which read it in the developer portal; the portal needs a script to show its
pages, and was not read again here.
## Left out
On purpose: kMeet's call that schedules a meeting with its calendar event (later,
one more update); the CalDAV fallback at `sync.infomaniak.com` (a bead of its own,
only if these calls prove unstable); a mint (the token lasts until it is revoked
and is switched off after a year unused); a delete; attendees; rooms; the address
book; the profile; a webhook, which the service has none of for the calendar; and
the single setup card, which yardr gives only to a pack it embeds.
# Setting up Infomaniak Calendar
The pack wires Infomaniak Calendar into the yard as the declared source
`infomaniak-calendar`: the calendars listed, a calendar's events listed in a
window and read, and timed events created and edited without inviting anyone.
Each call goes out with a token you make in Infomaniak's manager and type
unseen, so nothing is minted and no new copy of a secret is stored beyond the
one file only you can read. It is the twin of `google-calendar`, with that
pack's call names and arguments where the service has a counterpart; README.md
says where it has none, and what was and was not tried against the live
service (nothing was).
Every crew member granted a call acts as the one Infomaniak user the token was
made for. The token can do more than the pack's five calls, whatever the
yard grants: make it for a user whose calendars the yard may touch.
1. **The token, in Infomaniak's manager.** Log in to the manager, open the
token list, and create a token:
- Where: the token link in [Infomaniak's guide to API tokens (FAQ 2582)](https://www.infomaniak.com/en/support/faq/2582)
resolves to `https://manager.infomaniak.com/v3/ng/profile/user/token/list`.
I checked the link on 10 November 2026; the manager requires a login, so
its contents were not inspected. The calendar MCP server's README still
names `https://manager.infomaniak.com/v3/ng/accounts/token/list`.
- Name it for the yard, and choose the scopes `workspace:calendar` and
`user_info`, the pair that MCP server's README asks for.
- The validity is unlimited unless you set one. A token is switched off after
a year unused, and is revoked from the same list.
- The manager asks for your account password, then shows the token once.
Copy it for step 4. It goes in no file, note or message.
2. **Apply the pack.** As the operator, in a terminal. It shows the plan, the
digest and the one program the pack runs as you,
`infomaniak-calendar/build-edit`, which needs `curl` and `jq`, and asks:
```sh
yardr pack apply /Users/mckean/projects/repos/yardr-packs/infomaniak-calendar
# applied pack infomaniak-calendar to the yard: 1 created, 0 changed, 0 unchanged
# source infomaniak-calendar is declared: 5 calls under https://api.infomaniak.com (yardr source show infomaniak-calendar)
```
3. **The setup card.** There is none for this pack. `yardr bead ask <bead> -
--setup <pack> --crew <name>` makes one card of the apply, the account and
the grants, but only for a pack yardr carries in its binary, as it does
`google-calendar`. For a pack that is a directory, as this one is, yardr
answers `pack infomaniak-calendar: yardr embeds no pack of that name: a pack
that is a directory, a URL or an earlier import's record is the operator's
to apply in a terminal` (tried on 10 November 2026, with the yardr build
f17535e92091). So the setup is the steps around this one: the apply above,
the token (step 4) and the grants (step 5), which a crew member can ask for
on a card each. A single card would need the pack embedded in yardr, which
is a bead of the yardr depot.
4. **The token, given to the yard.** The operator types it unseen: the command
asks for the value and shows nothing as it is typed or pasted, and the
yard's server writes it to
`~/.yardr/sources/infomaniak-calendar.env`, a file only you can read.
```sh
yardr source key infomaniak-calendar
# source infomaniak-calendar: INFOMANIAK_CALENDAR_TOKEN is set in ~/.yardr/sources/infomaniak-calendar.env
```
Or have a crew member ask for it, and answer on its card, in footplate or in
`yardr chat` (`ctrl+o`), where the field shows dots. The card names where
the token goes, `https://api.infomaniak.com`, and the answer on the bead
holds the name given and never the value:
```sh
yardr bead ask <bead> "The Infomaniak token for the calendar" --secret infomaniak-calendar --context "In Infomaniak's manager, the token list: a token with the scopes workspace:calendar and user_info."
```
The yard sends the token to the host it was given for and to no other: a
copy of the declaration that names another base is refused before anything
is sent, until the token is given again.
5. **The grants.** A pack of kind source asks none at import, so what a crew
member may call is granted afterwards. `yardr pack show infomaniak-calendar`
prints the sets the pack suggests: `read`, the three read calls (`calendars`,
`events` and `event`, and any read call the source is later given);
`create`, those and `create`; `everything`, those and `edit`. Those sets
cannot be granted by name for a pack applied from a directory, so each
call is granted by its own, and the operator types them:
```sh
yardr source grant infomaniak-calendar read --crew yardmaster
```
```sh
yardr source grant infomaniak-calendar create --crew yardmaster
```
```sh
yardr source grant infomaniak-calendar edit --crew yardmaster
```
`read` covers every read call and no update; `create` and `edit` each change
something in the calendar and are granted by their names alone. A grantee
who comes later gets one card for each grant, which a crew member asks
for:
```sh
yardr bead ask <bead> "Let the reviewers read Infomaniak Calendar" --run "source grant infomaniak-calendar read --group reviewers" --context "The reviewers list and read calendar events to check a bead's work; the card names each call it covers."
```
`yardr source show infomaniak-calendar` lists each call with its kind and
who may make it.
6. **The limits.** Infomaniak allows 60 requests a minute. The yard holds a
caller to 60 calls an hour of a source and 10 of them updates, which is
stricter; an `edit` is two requests to Infomaniak, the read and the write.
If the crew needs more, the operator raises them:
```sh
yardr source set infomaniak-calendar calls_per_hour=300
```
7. **Check it.** The source's check call lists the calendars with the token the
yard was given, and the check says whether `edit`'s program can be run:
```sh
yardr source check infomaniak-calendar
# infomaniak-calendar: credential: INFOMANIAK_CALENDAR_TOKEN is set in ~/.yardr/sources/infomaniak-calendar.env
# infomaniak-calendar: check call calendars (GET /1/calendar/pim/calendar): 200 in 412ms, 214 bytes
# infomaniak-calendar: data: {"calendars":[{"id":41,"name":"Personal","default":true}]}
# infomaniak-calendar: command call.edit.build (infomaniak-calendar/build-edit): can be run
```
The listing is where the calendar ids are: every other call that names a
calendar takes the number `id` shown there, and the calendar marked
`default` is the account's own. A 401 or a 403 says the service did not
take the token for the calendar: it was revoked, was made for another
account, or lacks the scope `workspace:calendar`. (The lines above are the
shape of an answer, not one the service gave: see README.md.)
8. **Try it.** As the operator, who may make any call: the calendars, a week's
events, one event read, an event created:
```sh
yardr call infomaniak-calendar calendars
```
```sh
yardr call infomaniak-calendar events calendarId=<an id calendars showed> timeMin="2026-11-16 00:00:00" timeMax="2026-11-23 00:00:00"
```
```sh
yardr call infomaniak-calendar event eventId=<an id the listing showed>
```
```sh
yardr call infomaniak-calendar create calendarId=<an id> summary="Lunch with Ada" start="2026-11-20 12:00:00" end="2026-11-20 13:00:00" timezone=Europe/Zurich location="Cafe Roma"
```
An edit changes only what it names; the event is read first and sent back
whole, so what it leaves out stays, its attendees and alarms included:
```sh
yardr call infomaniak-calendar edit eventId=<the id create answered with> location="Cafe Novo"
```
A time is a wall clock, `YYYY-MM-DD HH:MM:SS`, with no offset: in the
account's own timezone for `events`, in `timezone` (an IANA name) for
`create`, and in the event's own timezone for `edit`. An event created or
edited invites no one and notifies no one.
What is not there, on purpose: inviting anyone (no attendees, and
`notifyAttendees` is false); moving or deleting an event; all-day events and
recurrence (an edit refuses an event that recurs, and the times of an all-day
one); booking a room; the address book; searching or paging an events list;
the kMeet meeting call; the CalDAV fallback at `sync.infomaniak.com`; and any
call the pack does not list. The yard's source format cannot express what is not
listed, and the pack does not change yardr.
#!/bin/sh
# The infomaniak-calendar pack's tests: what the gate runs beside yardr pack
# check. Needs no network and no secret, done in seconds. Needs jq, curl,
# python3 and yardr, and fails without any of them: a part that was skipped
# would leave the pack unpinned. What it holds is the pack read against
# fixtures shaped after Infomaniak's own MCP server for its calendar, not
# against the service: README.md, "What was checked", says what that leaves
# untried.
#
# build infomaniak-calendar/build-edit, the program that builds edit's
# PUT body, run by itself against a stand-in for the service on a
# loopback port (testdata/standin.py), one testdata/build/*.case
# each: the event it reads, the body it prints (only what was
# given changed, everything else as the event had it, nobody
# notified), and what it refuses in words with nothing sent. It
# reads and never writes: no request but a GET reaches the
# stand-in. The token reaches curl on its stdin and in no
# argument, and is in no output.
# core testdata/core.sh: the real yardr in a scratch yard. The
# declaration as the yard reads it, the presets and the grants
# SETUP.md teaches, the check, and each testdata/calls/*.case, one
# `yardr call` against the same stand-in. A case's golden holds
# the requests the stand-in saw (method, path and query exactly as
# they arrived, the body of a write), the exit status, what the
# call kept of the answer and what it said on stderr; a call the
# declaration should turn away shows as no request at all. The
# token is made here for the run and thrown away: the stand-in
# says only whether a request carried it.
#
# A case file is sections, each opened by a line "=== name": args (the call
# and its parameters, one word to a line), params (what build-edit is given
# on stdin), and routes (what the stand-in answers: METHOD path status
# answer, one to a line, the answer @name for testdata/answers/name.json; a
# request no line names is a 404). UPDATE_GOLDEN=1 ./test writes the goldens
# again from what the programs print; read the diff before keeping it.
set -u
cd "$(dirname "$0")" || exit 1
here=$(pwd)
fail=0
for tool in jq curl python3; do
command -v "$tool" >/dev/null 2>&1 || { echo "test: $tool is not on PATH: the pack needs it"; exit 1; }
done
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
[ -x "$YARDR" ] || { echo "test: no yardr ($YARDR): the declaration and the calls are read through it"; exit 1; }
tmp=$(mktemp -d)
# The core part's scratch yard is made when its turn comes, and the yard's
# server, which its first command starts, would outlive the directory it
# owns: stop it first. core is empty until then.
core=
standin_pid=
# The trap runs this, which shellcheck does not see.
# shellcheck disable=SC2329
cleanup() {
[ -z "${standin_pid:-}" ] || kill "$standin_pid" 2>/dev/null
if [ -n "${core:-}" ]; then
env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester server stop >/dev/null 2>&1
rm -rf "${core:?}"
fi
rm -rf "${tmp:?}"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
# check <name> <actual file> <golden file>: ok when they are the same.
check() {
if [ "${UPDATE_GOLDEN:-}" = 1 ]; then
if ! cmp -s "$2" "$3"; then cp "$2" "$3"; echo "test: $1 golden written"; fi
return
fi
if [ ! -e "$3" ]; then
echo "test: $1 has no golden $3 (UPDATE_GOLDEN=1 ./test writes it)"
fail=1
elif cmp -s "$2" "$3"; then
echo "test: $1 ok"
else
echo "test: $1 differs from $3:"
diff "$3" "$2" | sed 's/^/ /' | head -30
fail=1
fi
}
# ok <name> <condition's exit status> [detail]
ok() {
if [ "$2" = 0 ]; then echo "test: $1 ok"; else echo "test: $1 FAILED${3:+: $3}"; fail=1; fi
}
# ---- the token, made here and thrown away --------------------------------------
# A random value for each run: the stand-in knows it, the yard is given it as
# SETUP.md gives a token, and none of the pack's files can hold it.
token=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')
[ "${#token}" = 48 ] || { echo "test: no random token could be made"; exit 1; }
(umask 077 && printf '%s\n' "$token" >"$tmp/token")
# ---- the stand-in ----------------------------------------------------------------
: >"$tmp/requests"
: >"$tmp/routes"
python3 -I testdata/standin.py "$tmp/port" "$tmp/requests" "$tmp/routes" "$tmp/token" 2>"$tmp/standin.err" &
standin_pid=$!
i=0
while [ ! -s "$tmp/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
if [ ! -s "$tmp/port" ]; then
echo "test: the stand-in did not listen: $(cat "$tmp/standin.err")"
exit 1
fi
site=http://127.0.0.1:$(cat "$tmp/port")
# split_case <case file> <dir>: each section of the case becomes a file of
# its own in the dir, named for it.
split_case() {
awk -v dir="$2" '/^=== /{ name = substr($0, 5); f = dir "/" name; printf "" > f; next } name != "" { print >> f }' "$1"
}
# set_routes <routes file>: what the stand-in answers from now on. An answer
# @name is testdata/answers/name.json; a line that starts with # is a
# comment, and no file at all is no route.
set_routes() {
: >"$tmp/routes"
[ -e "$1" ] || return 0
while read -r method path status answer; do
case $method in '' | '#'*) continue ;; esac
case $answer in @*) answer=$here/testdata/answers/${answer#@}.json ;; esac
printf '%s %s %s %s\n' "$method" "$path" "$status" "$answer" >>"$tmp/routes"
done <"$1"
}
# golden_of <dir> <status>: the four parts of a case's golden, from the
# stand-in's log and the files the run left in the dir.
golden_of() {
echo "--- requests"
cat "$tmp/requests"
echo "--- status"
echo "$2"
echo "--- stdout"
if [ -s "$1/stdout" ]; then jq -S . "$1/stdout" 2>/dev/null || cat "$1/stdout"; fi
echo "--- stderr"
cat "$1/stderr"
}
# ---- build -----------------------------------------------------------------------
mkdir "$tmp/work" "$tmp/home"
# build_run <dir> <stdin file> [extra env...]: build-edit as the core runs a
# build, in a bare environment and from a directory of its own: stdout and
# stderr land in the dir, and the status is in $rc.
build_run() {
_d=$1 _in=$2
shift 2
(cd "$tmp/work" && env -i PATH="$PATH" HOME="$tmp/home" INFOMANIAK_CALENDAR_TOKEN="$token" YARDR_SOURCE_BASE="$site" "$@" "$here/infomaniak-calendar/build-edit" <"$_in" >"$_d/stdout" 2>"$_d/stderr")
rc=$?
}
for c in testdata/build/*.case; do
n=$(basename "$c" .case)
d=$tmp/build_$n
mkdir "$d"
split_case "$c" "$d"
set_routes "$d/routes"
: >"$tmp/requests"
build_run "$d" "$d/params"
golden_of "$d" "$rc" >"$d/golden"
check "build $n" "$d/golden" "testdata/build/$n.golden"
done
# What every run of the program did: it read and it never wrote, with the
# token, and the token is in none of what it printed.
cat "$tmp"/build_*/golden >"$tmp/build.all"
! grep -E '^[A-Z]+ ' "$tmp/build.all" | grep -vE '^GET /1/calendar/pim/event/[1-9][0-9]*$' | grep -q .
ok "build-edit's only requests are a GET of one event" $?
! grep -qE 'auth: (missing|WRONG)' "$tmp/build.all"
ok "every request of build-edit carried the token as a bearer" $?
! grep -qF "$token" "$tmp"/build_*/stdout "$tmp"/build_*/stderr
ok "the token is in no output of build-edit" $?
# The same program with what it needs left out, each refused in one line.
# refuses <what> <wanted on stderr> <stdin> [env, with PATH set by the caller]
refuses() {
_what=$1 _want=$2
d=$tmp/refuse
rm -rf "$d"
mkdir "$d"
printf '%s' "$3" >"$d/in"
shift 3
: >"$tmp/requests"
set_routes /dev/null
build_run "$d" "$d/in" "$@"
bad=
[ "$rc" = 1 ] || bad="$bad exit $rc, wanted 1;"
[ ! -s "$d/stdout" ] || bad="$bad stdout is not empty;"
[ "$(cat "$d/stderr")" = "$_want" ] || bad="$bad stderr is '$(cat "$d/stderr")';"
[ ! -s "$tmp/requests" ] || bad="$bad a request was made;"
grep -qF "$token" "$d/stderr" "$d/stdout" && bad="$bad the token is in the output;"
[ -z "$bad" ]
ok "build-edit refuses $_what, with nothing sent" $? "$bad"
}
good='{"params": {"eventId": "42", "summary": "x"}}'
refuses "no token" 'infomaniak-calendar/build-edit: INFOMANIAK_CALENDAR_TOKEN is not set' "$good" INFOMANIAK_CALENDAR_TOKEN=
refuses "a token with a line break" 'infomaniak-calendar/build-edit: INFOMANIAK_CALENDAR_TOKEN holds a line break' "$good" "INFOMANIAK_CALENDAR_TOKEN=$(printf 'a\nb')"
refuses "no base" 'infomaniak-calendar/build-edit: YARDR_SOURCE_BASE is not set' "$good" YARDR_SOURCE_BASE=
for tool in jq curl; do
mkdir "$tmp/without-$tool"
for p in cat tr jq curl; do
[ "$p" = "$tool" ] || ln -s "$(command -v "$p")" "$tmp/without-$tool/$p"
done
refuses "a PATH without $tool" "infomaniak-calendar/build-edit: needs $tool on PATH" "$good" "PATH=$tmp/without-$tool"
done
# The token goes to curl on its stdin, as a config line, and in no argument:
# an argument can be read by every process on the machine while curl runs.
# A curl of this run's own records what it was given, and runs the real one.
mkdir "$tmp/spy"
real_curl=$(command -v curl)
cat >"$tmp/spy/curl" <<EOF
#!/bin/sh
printf '%s\n' "\$@" >"$tmp/curl.argv"
tee "$tmp/curl.stdin" | "$real_curl" "\$@"
EOF
chmod +x "$tmp/spy/curl"
d=$tmp/spied
mkdir "$d"
set_routes /dev/null
printf '%s\n' "GET /1/calendar/pim/event/42 200 $here/testdata/answers/event-standup.json" >"$tmp/routes"
: >"$tmp/requests"
printf '%s' '{"params": {"eventId": "42", "summary": "x"}}' >"$d/in"
build_run "$d" "$d/in" "PATH=$tmp/spy:$PATH"
[ "$rc" = 0 ] && [ -s "$tmp/curl.argv" ] && [ -s "$tmp/curl.stdin" ] && ! grep -qF "$token" "$tmp/curl.argv" && grep -qF "Bearer $token" "$tmp/curl.stdin"
ok "build-edit hands curl the token on its stdin and in no argument" $? "exit $rc"
# ---- core ------------------------------------------------------------------------
# shellcheck source=testdata/core.sh
. ./testdata/core.sh
if [ "$fail" = 0 ]; then echo "test: infomaniak-calendar: all ok"; else echo "test: infomaniak-calendar: FAILED"; fi
exit $fail
{
"result": "success",
"data": {
"calendars": [
{ "id": 41, "name": "Personal", "default": true },
{ "id": 52, "name": "Team", "default": false }
]
}
}
{ "result": "success", "data": { "id": 47 } }
{ "result": "error", "error": { "code": "validation_failed", "description": "The end must be after the start" } }
{ "result": "error", "error": { "code": "validation_failed", "description": "The end must be after the start" } }
{ "result": "error", "error": { "code": "not_found", "description": "Event not found" } }
{ "result": "error", "error": { "code": "invalid_token", "description": "The access token provided is invalid" } }
{
"result": "success",
"data": {
"id": 44,
"calendar_id": 41,
"title": "Public holiday",
"description": "",
"start": "2026-10-26 00:00:00",
"end": "2026-10-27 00:00:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"color": "green",
"freebusy": "free",
"type": "event",
"fullday": true,
"private": false,
"attendees": [],
"alarms": [],
"attachments": [],
"rrule": "",
"meet_room_url": ""
}
}
{
"result": "success",
"data": {
"id": 46,
"calendar_id": 41,
"title": "Imported",
"description": "",
"start": "2026-10-28 10:00:00",
"end": "2026-10-28 11:00:00",
"timezone_start": null,
"timezone_end": "",
"freebusy": "busy",
"type": "event",
"fullday": false,
"private": false,
"attendees": [],
"alarms": [],
"attachments": [],
"rrule": "",
"meet_room_url": ""
}
}
{
"result": "success",
"data": {
"id": 43,
"calendar_id": 41,
"title": "Dentist",
"description": null,
"start": "2026-10-23 14:30:00",
"end": "2026-10-23 15:30:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"location": null,
"color": null,
"freebusy": "busy",
"type": "event",
"fullday": false,
"private": true,
"attendees": [],
"alarms": [],
"attachments": [],
"rrule": null,
"meet_room_url": null,
"bookable_resource_id": null
}
}
{
"result": "success",
"data": {
"id": 45,
"calendar_id": 52,
"title": "Weekly review",
"description": "",
"start": "2026-10-19T16:00:00+02:00",
"end": "2026-10-19T17:00:00+02:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"freebusy": "busy",
"type": "event",
"fullday": false,
"private": false,
"attendees": [],
"alarms": [],
"attachments": [],
"rrule": "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO",
"meet_room_url": ""
}
}
{
"result": "success",
"data": {
"id": 42,
"calendar_id": 41,
"title": "Standup",
"description": "Daily sync",
"start": "2026-10-22T09:00:00+02:00",
"end": "2026-10-22T09:15:00+02:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"location": "Salle Nyon",
"url": "https://meet.example.com/abc",
"categories": ["work"],
"color": "blue",
"sequence": 3,
"freebusy": "busy",
"type": "event",
"fullday": false,
"private": false,
"attendees": [
{ "className": "Attendee", "contactId": 95022716, "address": "ada@example.com", "name": "Ada Example", "state": "ACCEPTED", "organizer": true },
{ "className": "Attendee", "address": "bob@example.com", "name": "bob@example.com", "state": "NEEDS-ACTION", "organizer": false }
],
"alarms": [{ "trigger": "-PT15M" }],
"attachments": [],
"rrule": "",
"meet_room_url": "",
"bookable_resource_id": "res-uuid-1"
}
}
{ "result": "success", "data": [] }
{
"result": "success",
"data": [
{
"id": 42,
"calendar_id": 41,
"title": "Standup",
"description": "Daily sync",
"start": "2026-10-22T09:00:00+02:00",
"end": "2026-10-22T09:15:00+02:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"location": "Salle Nyon",
"url": "https://meet.example.com/abc",
"categories": [
"work"
],
"color": "blue",
"sequence": 3,
"freebusy": "busy",
"type": "event",
"fullday": false,
"private": false,
"attendees": [
{
"className": "Attendee",
"contactId": 95022716,
"address": "ada@example.com",
"name": "Ada Example",
"state": "ACCEPTED",
"organizer": true
},
{
"className": "Attendee",
"address": "bob@example.com",
"name": "bob@example.com",
"state": "NEEDS-ACTION",
"organizer": false
}
],
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"rrule": "",
"meet_room_url": "",
"bookable_resource_id": "res-uuid-1"
},
{
"id": 43,
"calendar_id": 41,
"title": "Dentist",
"description": null,
"start": "2026-10-23 14:30:00",
"end": "2026-10-23 15:30:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"location": null,
"color": null,
"freebusy": "busy",
"type": "event",
"fullday": false,
"private": true,
"attendees": [],
"alarms": [],
"attachments": [],
"rrule": null,
"meet_room_url": null,
"bookable_resource_id": null
},
{
"id": 44,
"calendar_id": 41,
"title": "Public holiday",
"description": "",
"start": "2026-10-26 00:00:00",
"end": "2026-10-27 00:00:00",
"timezone_start": "Europe/Zurich",
"timezone_end": "Europe/Zurich",
"color": "green",
"freebusy": "free",
"type": "event",
"fullday": true,
"private": false,
"attendees": [],
"alarms": [],
"attachments": [],
"rrule": "",
"meet_room_url": ""
}
]
}
{ "result": "success" }
{ "result": "success", "data": { "id": 42 } }
=== params
{"params": {"eventId": "44", "start": "2026-10-26 09:00:00"}}
=== routes
GET /1/calendar/pim/event/44 200 @event-allday
--- requests GET /1/calendar/pim/event/44 auth: ok accept: application/json user-agent: other --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: event 44 is an all-day event: its times are not changed here, only its title, description and location
=== params
{"params": {"eventId": "44", "summary": "Public holiday (CH)"}}
=== routes
GET /1/calendar/pim/event/44 200 @event-allday
--- requests
GET /1/calendar/pim/event/44
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [],
"attachments": [],
"attendees": [],
"calendar_id": 41,
"color": "green",
"description": "",
"end": "2026-10-27 00:00:00",
"freebusy": "free",
"fullday": true,
"id": 44,
"imip_request": false,
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": false,
"rrule": "",
"start": "2026-10-26 00:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Public holiday (CH)",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "4a", "summary": "x"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: eventId is no event number
=== params
{"params": {"eventId": "42", "description": ""}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"color": "blue",
"description": "",
"end": "2026-10-22 09:15:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"imip_request": false,
"location": "Salle Nyon",
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": false,
"rrule": "",
"start": "2026-10-22 09:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Standup",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "43", "end": "2026-10-23 16:00:00"}}
=== routes
GET /1/calendar/pim/event/43 200 @event-plain
--- requests
GET /1/calendar/pim/event/43
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [],
"attachments": [],
"attendees": [],
"bookable_resource_id": null,
"calendar_id": 41,
"color": null,
"description": "",
"end": "2026-10-23 16:00:00",
"freebusy": "busy",
"fullday": false,
"id": 43,
"imip_request": false,
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": true,
"rrule": "",
"start": "2026-10-23 14:30:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Dentist",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "042", "summary": "x"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: eventId is no event number
=== params
{"params": {"eventId": "42", "location": ""}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"color": "blue",
"description": "Daily sync",
"end": "2026-10-22 09:15:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"imip_request": false,
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": false,
"rrule": "",
"start": "2026-10-22 09:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Standup",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "43", "location": "Cabinet, Rue du Lac 1"}}
=== routes
GET /1/calendar/pim/event/43 200 @event-plain
--- requests
GET /1/calendar/pim/event/43
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [],
"attachments": [],
"attendees": [],
"bookable_resource_id": null,
"calendar_id": 41,
"color": null,
"description": "",
"end": "2026-10-23 15:30:00",
"freebusy": "busy",
"fullday": false,
"id": 43,
"imip_request": false,
"location": "Cabinet, Rue du Lac 1",
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": true,
"rrule": "",
"start": "2026-10-23 14:30:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Dentist",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "1234567890123456", "summary": "x"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: eventId is no event number
=== params
{"params": {"eventId": "42", "summary": "x"}}
=== routes
GET /1/calendar/pim/event/42 200 @no-data
--- requests GET /1/calendar/pim/event/42 auth: ok accept: application/json user-agent: other --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: event 42 could not be read: the answer has no event in data
=== params
{}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
--- stderr
infomaniak-calendar/build-edit: want {"params": {...}} on stdin
=== params
{"params": {"eventId": "42", "summary": "Réunion d'équipe — été", "description": "ligne un\nligne deux"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"color": "blue",
"description": "ligne un\nligne deux",
"end": "2026-10-22 09:15:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"imip_request": false,
"location": "Salle Nyon",
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": false,
"rrule": "",
"start": "2026-10-22 09:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Réunion d'équipe — été",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "99", "summary": "x"}}
=== routes
GET /1/calendar/pim/event/99 404 @error-not-found
--- requests GET /1/calendar/pim/event/99 auth: ok accept: application/json user-agent: other --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: event 99 could not be read: Event not found
=== params this is no JSON === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
--- stderr
infomaniak-calendar/build-edit: want {"params": {...}} on stdin
=== params
{"params": {"eventId": "42"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: nothing to change: give at least one of summary, start, end, description and location
=== params
{"params": {"eventId": "46", "summary": "Imported (renamed)"}}
=== routes
GET /1/calendar/pim/event/46 200 @event-nozone
--- requests GET /1/calendar/pim/event/46 auth: ok accept: application/json user-agent: other --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: event 46 has no timezone_start and timezone_end: the body needs the zone, and the account's own is not read here
=== params
{"params": {"eventId": "43", "summary": "Dentist (moved to Friday)"}}
=== routes
GET /1/calendar/pim/event/43 200 @event-plain
--- requests
GET /1/calendar/pim/event/43
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [],
"attachments": [],
"attendees": [],
"bookable_resource_id": null,
"calendar_id": 41,
"color": null,
"description": "",
"end": "2026-10-23 15:30:00",
"freebusy": "busy",
"fullday": false,
"id": 43,
"imip_request": false,
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": true,
"rrule": "",
"start": "2026-10-23 14:30:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Dentist (moved to Friday)",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "45", "summary": "Weekly review (new name)"}}
=== routes
GET /1/calendar/pim/event/45 200 @event-recurring
--- requests GET /1/calendar/pim/event/45 auth: ok accept: application/json user-agent: other --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: event 45 recurs: a change would be to the whole series, and a recurring event is only read here
=== params
{"params": {"eventId": "42", "start": "2026-10-22 10:00:00", "end": "2026-10-22 10:30:00"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"color": "blue",
"description": "Daily sync",
"end": "2026-10-22 10:30:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"imip_request": false,
"location": "Salle Nyon",
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": false,
"rrule": "",
"start": "2026-10-22 10:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Standup",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "42", "summary": "Standup (moved)"}}
=== routes
GET /1/calendar/pim/event/42 200 @event-standup
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
--- status
0
--- stdout
{
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"color": "blue",
"description": "Daily sync",
"end": "2026-10-22 09:15:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"imip_request": false,
"location": "Salle Nyon",
"meet_room_url": "",
"notifyAttendees": false,
"parent_updated": false,
"private": false,
"rrule": "",
"start": "2026-10-22 09:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Standup (moved)",
"type": "event"
}
--- stderr
=== params
{"params": {"eventId": "42", "summary": "x"}}
=== routes
GET /1/calendar/pim/event/42 401 @error-token
--- requests GET /1/calendar/pim/event/42 auth: ok accept: application/json user-agent: other --- status 1 --- stdout --- stderr infomaniak-calendar/build-edit: event 42 could not be read: The access token provided is invalid
=== args calendars === routes GET /1/calendar/pim/calendar 401 @error-token
--- requests
GET /1/calendar/pim/calendar
auth: ok
accept: application/json
user-agent: yardr
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call calendars: the service answered 401"
}
--- stderr
=== args calendars === routes GET /1/calendar/pim/calendar 200 @calendars
--- requests
GET /1/calendar/pim/calendar
auth: ok
accept: application/json
user-agent: yardr
--- status
0
--- stdout
{
"data": {
"calendars": [
{
"default": true,
"id": 41,
"name": "Personal"
},
{
"default": false,
"id": 52,
"name": "Team"
}
]
},
"result": "success"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich fullday=true === routes POST /1/calendar/pim/event 200 @created
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call create: \"fullday\" is no parameter of the call (it takes: calendarId, summary, start, end, timezone, description, location)"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich attendees=ada@example.com === routes POST /1/calendar/pim/event 200 @created
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call create: \"attendees\" is no parameter of the call (it takes: calendarId, summary, start, end, timezone, description, location)"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe Zurich === routes POST /1/calendar/pim/event 200 @created
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call create: parameter timezone does not match its pattern ^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich === routes POST /1/calendar/pim/event 200 @error-200
--- requests
POST /1/calendar/pim/event
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"attendees":[],"calendar_id":"41","end":"2026-10-24 13:00:00","freebusy":"busy","fullday":false,"notifyAttendees":false,"start":"2026-10-24 12:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"Lunch with Ada","type":"event"}
--- status
0
--- stdout
{
"error": {
"code": "validation_failed",
"description": "The end must be after the start"
},
"result": "error"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich === routes POST /1/calendar/pim/event 200 @created
--- requests
POST /1/calendar/pim/event
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"attendees":[],"calendar_id":"41","end":"2026-10-24 13:00:00","freebusy":"busy","fullday":false,"notifyAttendees":false,"start":"2026-10-24 12:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"Lunch with Ada","type":"event"}
--- status
0
--- stdout
{
"data": {
"id": 47
},
"result": "success"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 === routes POST /1/calendar/pim/event 200 @created
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call create: parameter timezone is missing"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24T12:00:00+02:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich === routes POST /1/calendar/pim/event 200 @created
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call create: parameter start does not match its pattern ^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich === routes POST /1/calendar/pim/event 422 @error-invalid
--- requests
POST /1/calendar/pim/event
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"attendees":[],"calendar_id":"41","end":"2026-10-24 13:00:00","freebusy":"busy","fullday":false,"notifyAttendees":false,"start":"2026-10-24 12:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"Lunch with Ada","type":"event"}
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call create: the service answered 422"
}
--- stderr
=== args create calendarId=41 summary=Lunch with Ada start=2026-10-24 12:00:00 end=2026-10-24 13:00:00 timezone=Europe/Zurich description=Bring the notes location=Café Roma === routes POST /1/calendar/pim/event 200 @created
--- requests
POST /1/calendar/pim/event
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"attendees":[],"calendar_id":"41","description":"Bring the notes","end":"2026-10-24 13:00:00","freebusy":"busy","fullday":false,"location":"Café Roma","notifyAttendees":false,"start":"2026-10-24 12:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"Lunch with Ada","type":"event"}
--- status
0
--- stdout
{
"data": {
"id": 47
},
"result": "success"
}
--- stderr
=== args delete eventId=42 === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar has no call \"delete\" (it has: calendars, events, event, create, edit)"
}
--- stderr
=== args edit eventId=x summary=x === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call edit: parameter eventId does not match its pattern ^[1-9][0-9]{0,14}$"
}
--- stderr
=== args edit eventId=42 calendarId=52 summary=x === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call edit: \"calendarId\" is no parameter of the call (it takes: eventId, summary, start, end, description, location)"
}
--- stderr
=== args edit eventId=99 summary=x === routes GET /1/calendar/pim/event/99 404 @error-not-found
--- requests
GET /1/calendar/pim/event/99
auth: ok
accept: application/json
user-agent: other
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call edit: infomaniak-calendar/build-edit: event 99 could not be read: Event not found"
}
--- stderr
=== args edit eventId=42 === routes GET /1/calendar/pim/event/42 200 @event-standup PUT /1/calendar/pim/event/42 200 @updated
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call edit: infomaniak-calendar/build-edit: nothing to change: give at least one of summary, start, end, description and location"
}
--- stderr
=== args edit eventId=42 summary=x === routes GET /1/calendar/pim/event/42 200 @event-standup PUT /1/calendar/pim/event/42 422 @error-invalid
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
PUT /1/calendar/pim/event/42
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"alarms":[{"trigger":"-PT15M"}],"attachments":[],"attendees":[{"address":"ada@example.com","className":"Attendee","contactId":95022716,"name":"Ada Example","organizer":true,"state":"ACCEPTED"},{"address":"bob@example.com","className":"Attendee","name":"bob@example.com","organizer":false,"state":"NEEDS-ACTION"}],"bookable_resource_id":"res-uuid-1","calendar_id":41,"color":"blue","description":"Daily sync","end":"2026-10-22 09:15:00","freebusy":"busy","fullday":false,"id":42,"imip_request":false,"location":"Salle Nyon","meet_room_url":"","notifyAttendees":false,"parent_updated":false,"private":false,"rrule":"","start":"2026-10-22 09:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"x","type":"event"}
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call edit: the service answered 422"
}
--- stderr
=== args edit eventId=45 summary=Weekly review (new name) === routes GET /1/calendar/pim/event/45 200 @event-recurring PUT /1/calendar/pim/event/45 200 @updated
--- requests
GET /1/calendar/pim/event/45
auth: ok
accept: application/json
user-agent: other
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call edit: infomaniak-calendar/build-edit: event 45 recurs: a change would be to the whole series, and a recurring event is only read here"
}
--- stderr
=== args edit eventId=42 start=2026-10-22 10:00:00 end=2026-10-22 10:30:00 === routes GET /1/calendar/pim/event/42 200 @event-standup PUT /1/calendar/pim/event/42 200 @updated
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
PUT /1/calendar/pim/event/42
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"alarms":[{"trigger":"-PT15M"}],"attachments":[],"attendees":[{"address":"ada@example.com","className":"Attendee","contactId":95022716,"name":"Ada Example","organizer":true,"state":"ACCEPTED"},{"address":"bob@example.com","className":"Attendee","name":"bob@example.com","organizer":false,"state":"NEEDS-ACTION"}],"bookable_resource_id":"res-uuid-1","calendar_id":41,"color":"blue","description":"Daily sync","end":"2026-10-22 10:30:00","freebusy":"busy","fullday":false,"id":42,"imip_request":false,"location":"Salle Nyon","meet_room_url":"","notifyAttendees":false,"parent_updated":false,"private":false,"rrule":"","start":"2026-10-22 10:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"Standup","type":"event"}
--- status
0
--- stdout
{
"data": {
"id": 42
},
"result": "success"
}
--- stderr
=== args edit eventId=42 summary=Standup (moved) === routes GET /1/calendar/pim/event/42 200 @event-standup PUT /1/calendar/pim/event/42 200 @updated
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: other
PUT /1/calendar/pim/event/42
auth: ok
accept: application/json
content-type: application/json
user-agent: yardr
body: {"alarms":[{"trigger":"-PT15M"}],"attachments":[],"attendees":[{"address":"ada@example.com","className":"Attendee","contactId":95022716,"name":"Ada Example","organizer":true,"state":"ACCEPTED"},{"address":"bob@example.com","className":"Attendee","name":"bob@example.com","organizer":false,"state":"NEEDS-ACTION"}],"bookable_resource_id":"res-uuid-1","calendar_id":41,"color":"blue","description":"Daily sync","end":"2026-10-22 09:15:00","freebusy":"busy","fullday":false,"id":42,"imip_request":false,"location":"Salle Nyon","meet_room_url":"","notifyAttendees":false,"parent_updated":false,"private":false,"rrule":"","start":"2026-10-22 09:00:00","timezone_end":"Europe/Zurich","timezone_start":"Europe/Zurich","title":"Standup (moved)","type":"event"}
--- status
0
--- stdout
{
"data": {
"id": 42
},
"result": "success"
}
--- stderr
=== args event eventId=abc === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call event: parameter eventId does not match its pattern ^[1-9][0-9]{0,14}$"
}
--- stderr
=== args event calendarId=41 eventId=42 === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call event: \"calendarId\" is no parameter of the call (it takes: eventId)"
}
--- stderr
=== args event eventId=99 === routes GET /1/calendar/pim/event/99 404 @error-not-found
--- requests
GET /1/calendar/pim/event/99
auth: ok
accept: application/json
user-agent: yardr
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call event: the service answered 404"
}
--- stderr
=== args event eventId=42 === routes GET /1/calendar/pim/event/42 200 @event-standup
--- requests
GET /1/calendar/pim/event/42
auth: ok
accept: application/json
user-agent: yardr
--- status
0
--- stdout
{
"data": {
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"categories": [
"work"
],
"color": "blue",
"description": "Daily sync",
"end": "2026-10-22T09:15:00+02:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"location": "Salle Nyon",
"meet_room_url": "",
"private": false,
"rrule": "",
"sequence": 3,
"start": "2026-10-22T09:00:00+02:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Standup",
"type": "event",
"url": "https://meet.example.com/abc"
},
"result": "success"
}
--- stderr
=== args events calendarId=4/1 timeMin=2026-10-20 00:00:00 timeMax=2026-10-27 23:59:59 === routes GET /1/calendar/pim/event 200 @events
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call events: parameter calendarId does not match its pattern ^[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*$"
}
--- stderr
=== args events calendarId=52 timeMin=2026-10-20 00:00:00 timeMax=2026-10-27 23:59:59 === routes GET /1/calendar/pim/event 200 @events-empty
--- requests
GET /1/calendar/pim/event?calendar_id=52&from=2026-10-20+00%3A00%3A00&to=2026-10-27+23%3A59%3A59
auth: ok
accept: application/json
user-agent: yardr
--- status
0
--- stdout
{
"data": [],
"result": "success"
}
--- stderr
=== args events timeMin=2026-10-20 00:00:00 timeMax=2026-10-27 23:59:59 === routes GET /1/calendar/pim/event 200 @events
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call events: parameter calendarId is missing"
}
--- stderr
=== args events calendarId=primary timeMin=2026-10-20 00:00:00 timeMax=2026-10-27 23:59:59 === routes GET /1/calendar/pim/event 200 @events
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call events: parameter calendarId does not match its pattern ^[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*$"
}
--- stderr
=== args events calendarId=41 timeMin=2026-10-20T00:00:00Z timeMax=2026-10-27 23:59:59 === routes GET /1/calendar/pim/event 200 @events
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call events: parameter timeMin does not match its pattern ^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
}
--- stderr
=== args events calendarId=41 timeMin=2026-10-20T00:00:00 timeMax=2026-10-27T23:59:59 === routes GET /1/calendar/pim/event 200 @events
--- requests
--- status
1
--- stdout
{
"error": "source infomaniak-calendar, call events: parameter timeMin does not match its pattern ^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
}
--- stderr
=== args events calendarId=41 timeMin=2026-10-20 00:00:00 timeMax=2026-10-27 23:59:59 === routes GET /1/calendar/pim/event 200 @events
--- requests
GET /1/calendar/pim/event?calendar_id=41&from=2026-10-20+00%3A00%3A00&to=2026-10-27+23%3A59%3A59
auth: ok
accept: application/json
user-agent: yardr
--- status
0
--- stdout
{
"data": [
{
"alarms": [
{
"trigger": "-PT15M"
}
],
"attachments": [],
"attendees": [
{
"address": "ada@example.com",
"className": "Attendee",
"contactId": 95022716,
"name": "Ada Example",
"organizer": true,
"state": "ACCEPTED"
},
{
"address": "bob@example.com",
"className": "Attendee",
"name": "bob@example.com",
"organizer": false,
"state": "NEEDS-ACTION"
}
],
"bookable_resource_id": "res-uuid-1",
"calendar_id": 41,
"categories": [
"work"
],
"color": "blue",
"description": "Daily sync",
"end": "2026-10-22T09:15:00+02:00",
"freebusy": "busy",
"fullday": false,
"id": 42,
"location": "Salle Nyon",
"meet_room_url": "",
"private": false,
"rrule": "",
"sequence": 3,
"start": "2026-10-22T09:00:00+02:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Standup",
"type": "event",
"url": "https://meet.example.com/abc"
},
{
"alarms": [],
"attachments": [],
"attendees": [],
"bookable_resource_id": null,
"calendar_id": 41,
"color": null,
"description": null,
"end": "2026-10-23 15:30:00",
"freebusy": "busy",
"fullday": false,
"id": 43,
"location": null,
"meet_room_url": null,
"private": true,
"rrule": null,
"start": "2026-10-23 14:30:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Dentist",
"type": "event"
},
{
"alarms": [],
"attachments": [],
"attendees": [],
"calendar_id": 41,
"color": "green",
"description": "",
"end": "2026-10-27 00:00:00",
"freebusy": "free",
"fullday": true,
"id": 44,
"meet_room_url": "",
"private": false,
"rrule": "",
"start": "2026-10-26 00:00:00",
"timezone_end": "Europe/Zurich",
"timezone_start": "Europe/Zurich",
"title": "Public holiday",
"type": "event"
}
],
"result": "success"
}
--- stderr
GET /1/calendar/pim/calendar auth: ok accept: application/json user-agent: yardr
# The core part of the infomaniak-calendar test, sourced by ./test: the
# declaration as the yard reads it, the presets and the grants SETUP.md
# teaches, the check call, and each testdata/calls/*.case made through the
# real yardr against the stand-in on a loopback port (testdata/standin.py).
# Everything runs in a scratch yard under a short path (a unix socket's path
# is held to 100 bytes) with its own home and an environment of nothing but
# PATH: none of this session's YARDR_* variables lead back to a real yard. No
# daemon is started, so no agent is, and the only listener is the stand-in's,
# on 127.0.0.1 and a port the system chooses.
#
# ./test has set $YARDR, $here, $tmp, $token, $site, check, ok, set_routes,
# golden_of and cleanup (which stops the stand-in by its pid, stops the
# scratch yard's server, and removes the scratch yard, whatever core is by
# then), and reads $fail after this file ends.
# shellcheck disable=SC2154,SC2034
core=$(mktemp -d /tmp/ikcalt.XXXXXX)
mkdir -p "$core/h"
yrun() { env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester "$@"; }
if ! yrun init --harness claude --operator tester >"$core/init.out" 2>&1; then
echo "test: core FAILED: the scratch yard could not be made: $(tail -c 300 "$core/init.out")"
fail=1
elif ! yrun pack apply "$here" --yes >"$core/apply.out" 2>&1; then
echo "test: core FAILED: the pack did not apply from its directory: $(tail -c 300 "$core/apply.out")"
fail=1
else
# -- the declaration as the yard reads it, with the shipped base
yrun source show infomaniak-calendar --json | jq -S .declaration >"$core/declaration.json"
check "declaration as the yard reads it (base, credential, calls)" "$core/declaration.json" testdata/declaration.golden
# The kinds are what keeps writing apart: read covers every read call and
# no update, and create and edit are granted by their names. What the pack
# never does is in the declaration as data: no delete, no attendees, and
# the one body that creates names nobody and notifies nobody.
jq -e '.base == "https://api.infomaniak.com" and .check == "calendars"
and .credential == {env: "INFOMANIAK_CALENDAR_TOKEN", header: "Authorization: Bearer {credential}"}
and (has("idempotency") | not) and (has("hook") | not) and (has("notify") | not) and (has("thread") | not) and (has("link") | not)
and ([.calls[] | select(.kind == "read") | .name] == ["calendars", "events", "event"])
and ([.calls[] | select(.kind == "update") | .name] == ["create", "edit"])
and ([.calls[] | select(.kind == "read") | .request.method] | all(. == "GET"))
and ([.calls[] | .request.method] | all(. == "GET" or . == "POST" or . == "PUT"))
and ([.calls[] | .params[]? | .name] | all(. != "attendees" and . != "sendUpdates" and . != "notifyAttendees"))
and ((.calls[] | select(.name == "create") | .request.body) | (.attendees == [] and .notifyAttendees == false and .fullday == false))
and ((.calls[] | select(.name == "edit") | .build) == ["infomaniak-calendar/build-edit"])' "$core/declaration.json" >/dev/null
ok "the shipped base is Infomaniak's, three reads, create and edit the two updates, nobody invited, no delete, no idempotency header, hook, notify, thread or link" $?
yrun pack show infomaniak-calendar >"$core/packshow.out" 2>&1
grep -q '1\. read: ' "$core/packshow.out" && grep -q '2\. create: ' "$core/packshow.out" && grep -q '3\. everything: ' "$core/packshow.out" &&
awk '/1\. read: /{ r = 1; next } /2\. create: /{ r = 2; next } /3\. everything: /{ r = 3; next }
r == 1 && /infomaniak-calendar:/ { one = $0 } r == 2 && /infomaniak-calendar:/ { two = $0 } r == 3 && /infomaniak-calendar:/ { three = $0 }
END { exit !(one ~ /infomaniak-calendar: read$/ && two ~ /infomaniak-calendar: read, create$/ && three ~ /infomaniak-calendar: read, create, edit$/) }' "$core/packshow.out"
ok "pack show prints the three presets: read, then create beside it, then edit too" $?
grep -qF 'FAQ 2582' SETUP.md &&
grep -qF 'https://manager.infomaniak.com/v3/ng/profile/user/token/list' SETUP.md
ok "SETUP.md names the exact manager path linked by Infomaniak's FAQ" $?
# -- the grants SETUP.md teaches, run as written. They are read out of
# SETUP.md and not copied here: a copy would pass whatever SETUP teaches,
# and --preset is refused for a pack applied from a directory, so SETUP.md
# teaches each call by its name. The card of SETUP.md grants the group
# reviewers, which a fresh yard has none of.
grep -o 'source grant [^`"]*' SETUP.md >"$core/grants"
if [ ! -s "$core/grants" ]; then
echo "test: grant FAILED: SETUP.md teaches no source grant command"
fail=1
elif ! yrun group add reviewers --runner manual >"$core/out" 2>&1; then
echo "test: grant FAILED: the group of the card could not be made: $(tail -c 300 "$core/out")"
fail=1
else
while IFS= read -r cmd; do
# A grant is plain words, so $cmd is split on purpose. Its stdin is
# /dev/null so that it cannot read the rest of the list.
# shellcheck disable=SC2086
if yrun $cmd </dev/null >"$core/out" 2>&1; then
echo "test: grant ok (yardr $cmd)"
else
echo "test: grant FAILED: yardr refused 'yardr $cmd', which SETUP.md teaches: $(tail -c 300 "$core/out")"
fail=1
fi
done <"$core/grants"
# What those grants gave: read as the word for the read calls, and
# create and edit as updates, each by its name; nothing else was granted.
yrun source grants --json >"$core/grants.json" 2>&1
jq -e 'length > 0
and ([.[] | .kinds | to_entries[]] | all((.key == "read" and .value == "read") or ((.key == "create" or .key == "edit") and .value == "update")))
and ([.[] | .kinds | has("read")] | any) and ([.[] | .kinds | has("create")] | any) and ([.[] | .kinds | has("edit")] | any)' "$core/grants.json" >/dev/null
ok "SETUP.md's grants give read as read, create and edit as updates, nothing else" $? "$(head -c 300 "$core/grants.json")"
fi
# -- the calls and the check, through the real yardr against the stand-in.
# The operator's copy is the declaration with its base replaced, as a
# person would set it; here the service is the stand-in, and the program
# edit names is the pack's own, copied beside it as the yard copies it.
mkdir -p "$core/mine"
cp -R "$here/infomaniak-calendar" "$core/mine/"
sed "s|^base: .*|base: $site|" "$here/infomaniak-calendar.yaml" >"$core/mine/infomaniak-calendar.yaml"
# The token is given as SETUP.md gives it: yardr source key, with the name
# on its standard input, which the server writes into the source's env
# file. (Typed at a terminal it is asked unseen; a script gives NAME=value.)
yrun source add "$core/mine/infomaniak-calendar.yaml" >"$core/add.out" 2>&1 \
&& printf 'INFOMANIAK_CALENDAR_TOKEN=%s\n' "$token" | yrun source key infomaniak-calendar >>"$core/add.out" 2>&1 \
&& yrun source set infomaniak-calendar calls_per_hour=1000 >>"$core/add.out" 2>&1 \
&& yrun source set infomaniak-calendar updates_per_hour=1000 >>"$core/add.out" 2>&1
ok "the operator's copy is added and the token given by source key" $? "$(sed "s/$token/<TOKEN>/g" "$core/add.out" | tail -c 300)"
# -- the check call, with the token the yard was given
: >"$tmp/requests"
set_routes /dev/null
printf '%s\n' "GET /1/calendar/pim/calendar 200 $here/testdata/answers/calendars.json" >"$tmp/routes"
yrun source check infomaniak-calendar >"$core/check.out" 2>&1
rc=$?
[ "$rc" = 0 ] && grep -q 'check call calendars (GET /1/calendar/pim/calendar): 200' "$core/check.out" && grep -q '"name":"Personal"' "$core/check.out" &&
grep -q 'command call.edit.build (infomaniak-calendar/build-edit): can be run' "$core/check.out"
ok "source check reaches the service with the token, shows the calendars, and finds edit's program" $? "exit $rc: $(sed "s/$token/<TOKEN>/g" "$core/check.out" | tail -c 300)"
check "the check call's request" "$tmp/requests" testdata/check.requests.golden
# Each case: args (the call and its parameters, one word to a line) and
# routes (what the stand-in answers; a request no line names is a 404).
for c in testdata/calls/*.case; do
n=$(basename "$c" .case)
d="$core/case_$n"
mkdir "$d"
split_case "$c" "$d"
set_routes "$d/routes"
: >"$tmp/requests"
set --
while IFS= read -r word; do set -- "$@" "$word"; done <"$d/args"
yrun call infomaniak-calendar "$@" --json >"$d/stdout" 2>"$d/stderr"
status=$?
golden_of "$d" "$status" >"$d/golden"
check "call $n" "$d/golden" "testdata/calls/$n.golden"
done
# Of all the requests the cases made: each carried the token, each is one of
# the five routes the declaration names and nothing else (no DELETE, no
# PATCH, no route beside the calendar's), and nothing that was sent invites
# or notifies anyone.
cat "$core"/case_*/golden >"$core/all.golden"
! grep -qE 'auth: (missing|WRONG)' "$core/all.golden"
ok "every request carried the token as a bearer" $?
! grep -E '^[A-Z]+ ' "$core/all.golden" | grep -vE '^(GET /1/calendar/pim/calendar|GET /1/calendar/pim/event(\?[^ ]*)?|GET /1/calendar/pim/event/[1-9][0-9]*|POST /1/calendar/pim/event|PUT /1/calendar/pim/event/[1-9][0-9]*)$' | grep -q .
ok "every request is one of the five routes of the declaration: no DELETE, no PATCH, nothing beside /1/calendar/pim" $?
! grep -E '^ body: ' "$core/all.golden" | grep -qE '"(notifyAttendees|imip_request)":true'
ok "no body notifies anyone: notifyAttendees and imip_request are never true" $?
awk '/^[A-Z]+ /{ method = $1 } /^ body: /{ if (method == "POST" && $0 !~ /"attendees":\[\]/) bad = 1; posts += (method == "POST") } END { exit (bad || !posts) }' "$core/all.golden"
ok "every event created has no attendees, and some were created" $?
! grep -E '^ content-type: ' "$core/all.golden" | grep -vqE '^ content-type: application/json$'
ok "every write is JSON" $?
! grep -E '^ user-agent: ' "$core/all.golden" | grep -vqE '^ user-agent: (yardr|other)$'
ok "the yard names itself as yardr" $?
# -- the token is in the env file the yard wrote and nowhere else
envfile=$core/y/sources/infomaniak-calendar.env
grep -qF "$token" "$envfile" && [ -n "$(find "$envfile" -type f -perm 600)" ]
ok "the env file holds the token, and only its owner reads it" $?
leaks=$(grep -rlF "$token" "$core/y" "$core/h" 2>/dev/null | grep -vxF "$envfile")
[ -z "$leaks" ]
ok "the token is in no other file of the yard" $? "found in: $leaks"
! grep -qF "$token" "$core/add.out" "$core/check.out" "$core"/case_*/stdout "$core"/case_*/stderr "$core"/case_*/golden
ok "the token is in no output of the yard's commands" $?
fi
{
"about": "Infomaniak Calendar events listed, read, created and edited without inviting anyone; calendars listed.",
"base": "https://api.infomaniak.com",
"calls": [
{
"about": "List the calendars, each with the id the other calls take as calendarId",
"answer": {
"max": 65536,
"select": [
"result",
"data",
"error"
]
},
"kind": "read",
"name": "calendars",
"request": {
"method": "GET",
"path": "/1/calendar/pim/calendar"
}
},
{
"about": "List a calendar's events in a window, in one answer. timeMin and timeMax are a wall clock in the account's own timezone, 'YYYY-MM-DD HH:MM:SS', with no offset and no T: Infomaniak reads them so, and an RFC 3339 value is refused before anything is sent. calendarId is an id calendars lists.",
"answer": {
"max": 262144,
"select": [
"result",
"data",
"error"
]
},
"kind": "read",
"name": "events",
"params": [
{
"log": true,
"max": 64,
"name": "calendarId",
"pattern": "^[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*$",
"type": "string"
},
{
"name": "timeMin",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
"type": "string"
},
{
"name": "timeMax",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/1/calendar/pim/event",
"query": [
{
"key": "calendar_id",
"value": "{calendarId}"
},
{
"key": "from",
"value": "{timeMin}"
},
{
"key": "to",
"value": "{timeMax}"
}
]
}
},
{
"about": "Read one event, by the id events lists",
"answer": {
"max": 65536,
"select": [
"result",
"data",
"error"
]
},
"kind": "read",
"name": "event",
"params": [
{
"log": true,
"max": 15,
"name": "eventId",
"pattern": "^[1-9][0-9]{0,14}$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/1/calendar/pim/event/{eventId}"
}
},
{
"about": "Create a timed event, inviting no one. start and end are a wall clock, 'YYYY-MM-DD HH:MM:SS', in timezone, an IANA name such as Europe/Zurich. calendarId is an id calendars lists.",
"answer": {
"max": 65536,
"select": [
"result",
"data",
"error"
]
},
"kind": "update",
"name": "create",
"params": [
{
"log": true,
"max": 64,
"name": "calendarId",
"pattern": "^[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*$",
"type": "string"
},
{
"max": 500,
"name": "summary",
"type": "string"
},
{
"name": "start",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
"type": "string"
},
{
"name": "end",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
"type": "string"
},
{
"max": 64,
"name": "timezone",
"pattern": "^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$",
"type": "string"
},
{
"max": 10000,
"name": "description",
"optional": true,
"type": "string"
},
{
"max": 500,
"name": "location",
"optional": true,
"type": "string"
}
],
"request": {
"body": {
"attendees": [],
"calendar_id": "{calendarId}",
"description": "{description}",
"end": "{end}",
"freebusy": "busy",
"fullday": false,
"location": "{location}",
"notifyAttendees": false,
"start": "{start}",
"timezone_end": "{timezone}",
"timezone_start": "{timezone}",
"title": "{summary}",
"type": "event"
},
"method": "POST",
"path": "/1/calendar/pim/event"
}
},
{
"about": "Change an event, only what is given: it is read, changed and sent back whole, so its attendees, alarms and the rest stay as they were. start and end are a wall clock in the event's own timezone. An event that recurs is refused, and so are the times of an all-day one.",
"answer": {
"max": 65536,
"select": [
"result",
"data",
"error"
]
},
"build": [
"infomaniak-calendar/build-edit"
],
"kind": "update",
"name": "edit",
"params": [
{
"log": true,
"max": 15,
"name": "eventId",
"pattern": "^[1-9][0-9]{0,14}$",
"type": "string"
},
{
"max": 500,
"name": "summary",
"optional": true,
"type": "string"
},
{
"name": "start",
"optional": true,
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
"type": "string"
},
{
"name": "end",
"optional": true,
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
"type": "string"
},
{
"max": 10000,
"name": "description",
"optional": true,
"type": "string"
},
{
"max": 500,
"name": "location",
"optional": true,
"type": "string"
}
],
"request": {
"method": "PUT",
"path": "/1/calendar/pim/event/{eventId}"
}
}
],
"check": "calendars",
"credential": {
"env": "INFOMANIAK_CALENDAR_TOKEN",
"header": "Authorization: Bearer {credential}"
},
"name": "infomaniak-calendar"
}
# A stand-in for Infomaniak's calendar API, for the calls and the check the
# test makes through the real yardr, and for the build command's own reads.
# It answers each request from a routes file, read again for every request,
# so one process serves every case in turn. A line of that file is
#
# <METHOD> <path> <status> <answer file>
#
# the path without its query, the status an HTTP status, the answer file the
# path of a file whose bytes are the body. A request no line names is
# answered 404 with the service's error shape, so a request that is not the
# one expected shows in a golden. It records each request in the log it is
# given: the method, and the path and query exactly as they arrived; whether
# Authorization held the bearer token it was given; the Accept and
# Content-Type headers, and the User-Agent where it is yardr's (anything else
# is named other, since curl's differs from one machine to the next); and the
# JSON body with its keys in order of the alphabet. Any method is recorded,
# so that a DELETE or a PATCH the pack never sends shows if it ever does.
#
# The token is not in the log: it is read from the token file at the start,
# and a request is "ok" when its header is that token as a bearer, "missing"
# when it has none, "WRONG" otherwise. It writes the port it bound, on
# 127.0.0.1, to the file it is given once it listens.
#
# python3 -I standin.py <port file> <request log> <routes file> <token file>
import http.server
import json
import os
import sys
port_file, log_file, routes_file, token_file = sys.argv[1:5]
with open(token_file) as f:
token = f.read().strip()
def route(method, path):
"""Returns (status, body bytes) for the first line that names the request."""
with open(routes_file) as f:
for line in f:
parts = line.split()
if len(parts) == 4 and parts[0] == method and parts[1] == path:
with open(parts[3], "rb") as answer:
return int(parts[2]), answer.read()
body = {"result": "error", "error": {"code": "route_not_found", "description": "no route for %s %s" % (method, path)}}
return 404, json.dumps(body).encode()
class Handler(http.server.BaseHTTPRequestHandler):
def handle_any(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b""
header = self.headers.get("Authorization")
if not header:
auth = "missing"
else:
auth = "ok" if header == "Bearer " + token else "WRONG"
lines = ["%s %s" % (self.command, self.path), " auth: %s" % auth]
for name in ("Accept", "Content-Type"):
if self.headers.get(name) is not None:
lines.append(" %s: %s" % (name.lower(), self.headers.get(name)))
agent = self.headers.get("User-Agent")
if agent is not None:
lines.append(" user-agent: %s" % ("yardr" if agent == "yardr" else "other"))
if raw:
try:
lines.append(" body: " + json.dumps(json.loads(raw), sort_keys=True, separators=(",", ":"), ensure_ascii=False))
except ValueError:
lines.append(" body: NOT JSON")
with open(log_file, "a") as f:
f.write("\n".join(lines) + "\n")
status, body = route(self.command, self.path.split("?", 1)[0])
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = handle_any
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()